fix: 修复35个安全与功能缺陷,补全知识进化/数字孪生/行为采集模块

## 安全修复 (12项)
- Webhook接口添加全局Token认证,过滤敏感请求头
- 修复JWT Base64 padding公式,防止签名验证绕过
- 数据库密码/飞书Token从源码移除,改为环境变量
- 工作流引擎添加路径遍历防护 (_resolve_safe_path)
- eval()添加模板长度上限检查
- 审批API添加认证依赖
- 前端v-html增强XSS转义,console.log仅开发模式输出
- 500错误不再暴露内部异常详情

## Agent运行时修复 (7项)
- 删除_inject_knowledge_context中未定义db变量的finally块
- 工具执行添加try/except保护,异常不崩溃Agent
- LLM重试计入budget计数器
- self_review异常时passed=False
- max_iterations截断标记success=False
- 工具参数JSON解析失败时记录警告日志
- run()开始时重置_llm_invocations计数器

## 配置与基础设施
- DEBUG默认False,SQL_ECHO独立配置项
- init_db()补全13个缺失模型导入
- 新增WEBHOOK_AUTH_TOKEN/SQL_ECHO配置项
- 新增.env.example模板文件

## 前端修复 (12项)
- 登录改用URLSearchParams替代FormData
- 401拦截器通过Pinia store统一清理状态
- SSE流超时从60s延长至300s
- final/error事件时清除streamTimeout
- localStorage聊天记录添加24h TTL
- safeParseArgCount替代模板中裸JSON.parse
- fetchUser 401时同时清除user对象

## 新增模块
- 知识进化: knowledge_extractor/retriever/tasks
- 数字孪生: shadow_executor/comparison模型
- 行为采集: behavior_middleware/collector/fingerprint_engine
- 代码审查: code_review_agent/document_review_agent
- 反馈学习: feedback_learner
- 瓶颈检测/优化引擎/成本估算/需求估算
- 速率限制器 (rate_limiter)
- Alembic迁移 015-020

## 文档
- 商业化落地计划
- 8篇docs文档 (架构/API/部署/开发/贡献等)
- Docker Compose生产配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-10 19:50:20 +08:00
parent f79dc0b3c6
commit ab1589921a
77 changed files with 9442 additions and 265 deletions

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# 运行阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 前端静态资源
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端
location /api/ {
proxy_pass http://backend:8037;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 60s;
}
# WebSocket 代理
location /ws/ {
proxy_pass http://backend:8037;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 健康检查
location /health {
return 200 "OK";
}
}

View File

@@ -80,7 +80,12 @@ api.interceptors.response.use(
// 处理401未授权
if (status === 401) {
localStorage.removeItem('token')
// 延迟导入避免循环依赖:通过 Pinia store 统一清理认证状态
import('@/stores/user').then(({ useUserStore }) => {
useUserStore().logout()
}).catch(() => {
localStorage.removeItem('token')
})
router.push('/login')
ElMessage.error(data?.message || '登录已过期,请重新登录')
return Promise.reject(error)
@@ -141,7 +146,9 @@ api.interceptors.response.use(
data?.message ||
'服务器内部错误,请稍后重试'
ElMessage.error(message)
console.error('服务器错误:', data)
if (import.meta.env.DEV) {
console.error('服务器错误:', data)
}
return Promise.reject(error)
}

View File

@@ -160,6 +160,18 @@ const router = createRouter({
component: () => import('@/views/DigitalEmployeeFactory.vue'),
meta: { requiresAuth: true }
},
{
path: '/digital-twin',
name: 'digital-twin',
component: () => import('@/views/DigitalTwin.vue'),
meta: { requiresAuth: true }
},
{
path: '/knowledge-dashboard',
name: 'knowledge-dashboard',
component: () => import('@/views/KnowledgeDashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/goals/:id',
name: 'goal-detail',

View File

@@ -142,6 +142,11 @@ export const useGoalStore = defineStore('goal', () => {
return response.data
}
const interactWithGoal = async (goalId: string, message: string) => {
const response = await api.post(`/api/v1/goals/${goalId}/interact`, { message })
return response.data
}
// ─── Tasks ───
const fetchTaskTree = async (goalId: string) => {
@@ -189,6 +194,7 @@ export const useGoalStore = defineStore('goal', () => {
goals, currentGoal, taskTree, tasks, loading,
fetchGoals, fetchGoal, createGoal, updateGoal, deleteGoal,
decomposeGoal, startGoal, pauseGoal, resumeGoal, executeGoalAsync, replanGoal,
interactWithGoal,
fetchTaskTree, fetchTasks, fetchTask, updateTask,
approveTask, rejectTask, checkTaskDeps,
}

View File

@@ -27,11 +27,11 @@ export const useUserStore = defineStore('user', () => {
// 登录
const login = async (username: string, password: string) => {
const formData = new FormData()
formData.append('username', username)
formData.append('password', password)
const response = await api.post('/api/v1/auth/login', formData, {
const params = new URLSearchParams()
params.append('username', username)
params.append('password', password)
const response = await api.post('/api/v1/auth/login', params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
@@ -67,8 +67,9 @@ export const useUserStore = defineStore('user', () => {
user.value = response.data
return response.data
} catch (error: any) {
// 如果401错误,清除token
// 如果401错误,清除token和user对象
if (error.response?.status === 401) {
user.value = null
clearToken()
}
throw error

View File

@@ -95,7 +95,7 @@
</div>
<div v-for="(tc, j) in msg.tool_calls" :key="j" class="tool-call-item">
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">{{ Object.keys(JSON.parse(tc.function?.arguments || '{}')).length }} 个参数</el-tag>
<el-tag size="small" type="info">{{ safeParseArgCount(tc.function?.arguments) }} 个参数</el-tag>
</div>
</div>
@@ -286,7 +286,8 @@ function saveState() {
chatMode: chatMode.value,
orchestrateMode: orchestrateMode.value,
orchestrateAgents: orchestrateAgents.value,
}
_savedAt: Date.now(),
} as any
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch { /* quota exceeded, ignore */ }
}
@@ -296,6 +297,11 @@ function loadState(): ChatState | null {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
// TTL 过期检查:24小时后自动清除聊天记录
if (parsed._savedAt && Date.now() - parsed._savedAt > 24 * 3600 * 1000) {
localStorage.removeItem(STORAGE_KEY)
return null
}
// 兼容旧格式:旧版本 messages 是数组,迁移为 Record
if (Array.isArray(parsed.messages)) {
const oldSessionId = parsed.sessionId || ''
@@ -442,7 +448,7 @@ async function sendMessage() {
let placeholderIdx = -1
streamingActive.value = false
const abortController = new AbortController()
const streamTimeout = setTimeout(() => abortController.abort(), 60000)
const streamTimeout = setTimeout(() => abortController.abort(), 300000) // 5分钟超时,适应复杂 Agent 任务
try {
const token = localStorage.getItem('token') || ''
const resp = await fetch(streamEndpoint, {
@@ -526,6 +532,7 @@ async function sendMessage() {
approvalArgs.value = data.args || {}
showApprovalDialog.value = true
} else if (eventType === 'final') {
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''
currentMsg.iterations = data.iterations_used || 0
currentMsg.tool_calls_made = data.tool_calls_made || 0
@@ -535,6 +542,7 @@ async function sendMessage() {
streamingActive.value = false
loading.value = false
} else if (eventType === 'error') {
clearTimeout(streamTimeout)
currentMsg.content = data.content || ''
currentMsg.status = 'error'
streamingActive.value = false
@@ -598,6 +606,13 @@ function clearChat() {
}
function scrollToBottom() { nextTick(() => { if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight }) }
function safeParseArgCount(args?: string): number {
if (!args) return 0
try {
return Object.keys(JSON.parse(args)).length
} catch { return 0 }
}
function relativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
@@ -661,7 +676,9 @@ function retryMessage(idx: number) {
function renderMarkdown(text: string): string {
if (!text) return ''
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
// XSS 防护: 先转义 HTML 特殊字符,再对安全模式做 Markdown 渲染
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')

View File

@@ -151,6 +151,76 @@
<div class="form-tip">勾选的工具 Agent 可在运行时自主调用,不勾选则使用全部</div>
</el-form-item>
</el-form>
<!-- Agent 使用统计 -->
<el-divider content-position="left">使用统计(近7天)</el-divider>
<el-row :gutter="16" v-loading="statsLoading">
<el-col :span="6">
<el-card shadow="hover" class="mini-stat-card">
<div class="stat-num">{{ agentStats.call_count || 0 }}</div>
<div class="stat-desc">LLM 调用次数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="mini-stat-card">
<div class="stat-num">{{ fmtLatency(agentStats.avg_latency_ms) }}</div>
<div class="stat-desc">平均响应延迟</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="mini-stat-card">
<div class="stat-num">{{ agentStats.tool_call_count || 0 }}</div>
<div class="stat-desc">工具调用次数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="mini-stat-card">
<div class="stat-num">{{ fmtTokens(agentStats.total_tokens) }}</div>
<div class="stat-desc">Token 总用量</div>
</el-card>
</el-col>
</el-row>
<!-- 内嵌快速测试 -->
<el-divider content-position="left">
<span>快速测试</span>
<el-button size="small" text type="primary" @click="showTestPanel = !showTestPanel" style="margin-left:12px">
{{ showTestPanel ? '收起' : '展开' }}
</el-button>
</el-divider>
<div v-if="showTestPanel" class="inline-chat-panel">
<div class="chat-messages-inline" ref="chatContainerRef">
<div v-if="testMessages.length === 0" class="chat-empty">
输入消息测试当前 Agent 配置效果(不会保存)
</div>
<div
v-for="(msg, idx) in testMessages"
:key="idx"
:class="['chat-bubble', msg.role === 'user' ? 'is-user' : 'is-agent']"
>
<div class="bubble-role">{{ msg.role === 'user' ? '你' : agent?.name || 'Agent' }}</div>
<div class="bubble-text" v-text="msg.content"></div>
</div>
<div v-if="testLoading" class="chat-bubble is-agent">
<div class="bubble-role">{{ agent?.name || 'Agent' }}</div>
<div class="bubble-text typing-indicator">思考中...</div>
</div>
</div>
<div class="chat-input-area">
<el-input
v-model="testInput"
placeholder="输入测试消息,回车发送..."
@keyup.enter="handleTestSend"
:disabled="testLoading"
>
<template #append>
<el-button @click="handleTestSend" :loading="testLoading" :disabled="!testInput.trim()">
发送
</el-button>
</template>
</el-input>
</div>
</div>
</el-card>
<!-- Prompt 模板选择对话框 -->
@@ -170,7 +240,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Collection } from '@element-plus/icons-vue'
@@ -179,6 +249,7 @@ import PromptTemplatePicker from '@/components/PromptTemplatePicker.vue'
import { useAgentStore } from '@/stores/agent'
import { useModelConfigStore } from '@/stores/modelConfig'
import { BUILTIN_SKILL_OPTIONS } from '@/utils/agentSkills'
import api from '@/api'
const route = useRoute()
const router = useRouter()
@@ -238,6 +309,9 @@ onMounted(async () => {
loading.value = false
}
// 加载 Agent 统计数据
loadAgentStats(agentId)
// 加载模型配置列表
try {
await modelConfigStore.fetchModelConfigs()
@@ -284,6 +358,83 @@ function goBack() {
router.push('/agents')
}
// ── Agent 使用统计 ──
const statsLoading = ref(false)
const agentStats = ref({
call_count: 0,
avg_latency_ms: 0,
tool_call_count: 0,
total_tokens: 0,
})
function fmtLatency(ms: number) {
if (!ms) return '0ms'
if (ms < 1000) return `${Math.round(ms)}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function fmtTokens(n: number) {
if (!n) return '0'
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
return String(n)
}
async function loadAgentStats(agentId: string) {
statsLoading.value = true
try {
const res = await api.get('/api/v1/agent-monitoring/agents-stats', { params: { days: 7 } })
const list = res.data || []
const found = list.find((s: any) => s.agent_id === agentId)
if (found) agentStats.value = found
} catch {
// 统计非关键功能
} finally {
statsLoading.value = false
}
}
// ── 内嵌快速测试 ──
const showTestPanel = ref(false)
const testInput = ref('')
const testLoading = ref(false)
const testMessages = ref([] as Array<{ role: string; content: string }>)
const chatContainerRef = ref<HTMLElement>()
function scrollChatToBottom() {
nextTick(() => {
const el = chatContainerRef.value
if (el) el.scrollTop = el.scrollHeight
})
}
async function handleTestSend() {
const msg = testInput.value.trim()
if (!msg || testLoading.value) return
if (!agent.value?.id) {
ElMessage.warning('请先保存 Agent 配置后再测试')
return
}
testInput.value = ''
testMessages.value.push({ role: 'user', content: msg })
scrollChatToBottom()
testLoading.value = true
try {
const res = await api.post(`/api/v1/agent-chat/${agent.value.id}`, { message: msg })
const reply = res.data?.content || res.data?.reply || res.data?.text || JSON.stringify(res.data)
testMessages.value.push({ role: 'agent', content: reply })
} catch (e: any) {
const err = e.response?.data?.detail || e.message || '请求失败'
testMessages.value.push({ role: 'agent', content: `[错误] ${err}` })
} finally {
testLoading.value = false
scrollChatToBottom()
}
}
async function handleSave() {
if (!agent.value?.id) return
@@ -392,4 +543,84 @@ async function handleSave() {
color: var(--el-text-color-secondary);
margin-left: 8px;
}
/* 统计卡片 */
.mini-stat-card {
text-align: center;
cursor: default;
}
.mini-stat-card .stat-num {
font-size: 28px;
font-weight: 700;
color: var(--el-color-primary);
line-height: 1.2;
}
.mini-stat-card .stat-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 6px;
}
/* 内嵌聊天面板 */
.inline-chat-panel {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
overflow: hidden;
}
.chat-messages-inline {
max-height: 360px;
overflow-y: auto;
padding: 16px;
background: var(--el-bg-color-page);
}
.chat-empty {
text-align: center;
color: var(--el-text-color-placeholder);
padding: 40px 0;
font-size: 14px;
}
.chat-bubble {
margin-bottom: 14px;
display: flex;
flex-direction: column;
}
.chat-bubble.is-user {
align-items: flex-end;
}
.chat-bubble.is-agent {
align-items: flex-start;
}
.bubble-role {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.bubble-text {
max-width: 75%;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.is-user .bubble-text {
background: var(--el-color-primary);
color: #fff;
border-bottom-right-radius: 4px;
}
.is-agent .bubble-text {
background: #fff;
border: 1px solid var(--el-border-color-light);
border-bottom-left-radius: 4px;
}
.typing-indicator {
color: var(--el-text-color-secondary);
font-style: italic;
}
.chat-input-area {
padding: 12px 16px;
border-top: 1px solid var(--el-border-color-light);
background: #fff;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<MainLayout>
<div class="digital-twin-page">
<el-card class="header-card">
<div class="page-header">
<h2>个人数字分身</h2>
<div class="header-actions">
<el-select v-model="days" style="width: 140px; margin-right: 12px">
<el-option :value="7" label="最近7天" />
<el-option :value="14" label="最近14天" />
<el-option :value="30" label="最近30天" />
</el-select>
<el-button @click="refreshAll" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
</el-card>
<!-- 当前阶段 -->
<el-row :gutter="20" class="stage-row">
<el-col :span="6">
<el-card class="stage-card">
<div class="stage-badge" :class="phase">
{{ phaseLabel }}
</div>
<div class="stage-info">
<div class="stage-title">当前阶段</div>
<div class="stage-desc">{{ phaseDesc }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-mini-card" shadow="hover">
<div class="stat-value">{{ accuracy }}%</div>
<div class="stat-label">影子模式准确率</div>
<el-progress :percentage="accuracy" :color="accuracyColor" :stroke-width="8" />
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-mini-card" shadow="hover">
<div class="stat-value">{{ totalBehaviors }}</div>
<div class="stat-label">行为学习数据量</div>
<el-progress :percentage="Math.min(totalBehaviors / 5, 100)" :stroke-width="8" />
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-mini-card" shadow="hover">
<div class="stat-value">{{ unlockedCount }} / {{ totalCategories }}</div>
<div class="stat-label">已解锁类别</div>
<el-progress :percentage="unlockPercent" :stroke-width="8" color="#67c23a" />
</el-card>
</el-col>
</el-row>
<!-- Tabs: 能力详情 / 授权管理 / 操作日志 -->
<el-card class="main-content">
<el-tabs v-model="activeTab">
<!-- 能力详情 -->
<el-tab-pane label="能力详情" name="abilities">
<el-row :gutter="20">
<el-col :span="6" v-for="cat in categories" :key="cat.key">
<el-card class="ability-card" shadow="hover">
<div class="ability-header">
<h4>{{ cat.label }}</h4>
<el-tag :type="cat.unlocked ? 'success' : 'info'" size="small">
{{ cat.unlocked ? '已解锁' : '观察中' }}
</el-tag>
</div>
<div class="ability-body">
<div class="ability-stat">
<span>准确率</span>
<strong>{{ cat.accuracy }}%</strong>
</div>
<el-progress
:percentage="cat.accuracy"
:color="cat.unlocked ? '#67c23a' : '#909399'"
:stroke-width="6"
/>
<div class="ability-stat" style="margin-top: 12px">
<span>对比次数</span>
<strong>{{ cat.total }}</strong>
</div>
<div class="ability-threshold">
解锁阈值: {{ getThreshold(cat.key) * 100 }}%
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<!-- 授权管理 -->
<el-tab-pane label="授权管理" name="authorization">
<div class="auth-description">
<p>L0-L4 五级授权体系,根据风险等级自动判定数字分身能否自主执行。</p>
</div>
<el-table :data="authLevels" style="width: 100%">
<el-table-column prop="level" label="等级" width="80" />
<el-table-column prop="name" label="名称" width="160" />
<el-table-column prop="description" label="说明" min-width="200" />
<el-table-column label="需要确认" width="100">
<template #default="{ row }">
<el-tag :type="row.requires_approval ? 'warning' : 'success'" size="small">
{{ row.requires_approval ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="示例" min-width="200">
<template #default="{ row }">
<el-tag v-for="ex in row.examples" :key="ex" size="small" style="margin: 2px">
{{ ex }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="当前状态" width="120">
<template #default="{ row }">
<el-switch
:model-value="row.enabled"
:disabled="row.level === 'L4'"
@change="(val) => toggleAuthLevel(row.level, val)"
/>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="logs">
<el-table :data="logs" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column prop="category" label="场景" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="user_action" label="用户操作" width="100">
<template #default="{ row }">
<el-tag
:type="actionTagType(row.user_action)"
size="small"
>
{{ row.user_action }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="match_score" label="匹配度" width="100">
<template #default="{ row }">
<el-progress
:percentage="(row.match_score || 0) * 100"
:stroke-width="6"
:color="row.match_score > 0.8 ? '#67c23a' : row.match_score > 0.5 ? '#e6a23c' : '#f56c6c'"
/>
</template>
</el-table-column>
<el-table-column label="建议摘要" min-width="300">
<template #default="{ row }">
<span class="suggestion-text">{{ getSuggestionSummary(row) }}</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 行为统计 -->
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="12">
<el-card>
<template #header>
<h4>行为类别分布</h4>
</template>
<div class="category-distribution">
<div v-for="item in behaviorDistribution" :key="item.name" class="dist-row">
<span class="dist-label">{{ item.name }}</span>
<el-progress
:percentage="item.percent"
:stroke-width="14"
:color="item.color"
style="flex: 1; margin: 0 12px"
/>
<span class="dist-value">{{ item.count }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<h4>偏好权重 (代码评审)</h4>
</template>
<div class="preference-radar">
<div v-for="(weight, key) in preferences" :key="key" class="pref-row">
<span class="pref-label">{{ prefLabels[key] || key }}</span>
<el-progress
:percentage="Math.round(weight * 100)"
:stroke-width="12"
:color="prefColor(key)"
/>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</MainLayout>
</template>
<script>
import { defineComponent, ref, reactive, onMounted, computed } from 'vue'
import MainLayout from '@/layouts/MainLayout.vue'
import api from '@/api'
export default defineComponent({
name: 'DigitalTwin',
components: { MainLayout },
setup() {
const loading = ref(false)
const days = ref(30)
const activeTab = ref('abilities')
const accuracy = ref(0)
const totalBehaviors = ref(0)
const phase = ref('observe')
const logs = ref([])
const preferences = ref({})
const categories = reactive([
{ key: 'code_review', label: '代码评审', accuracy: 0, total: 0, unlocked: false },
{ key: 'email', label: '邮件处理', accuracy: 0, total: 0, unlocked: false },
{ key: 'document', label: '文档审查', accuracy: 0, total: 0, unlocked: false },
{ key: 'decision', label: '决策辅助', accuracy: 0, total: 0, unlocked: false },
])
const thresholds = { code_review: 0.85, email: 0.90, document: 0.85, decision: 0.90 }
const authLevels = reactive([
{ level: 'L0', name: '全自动执行', description: '低风险操作,直接执行', requires_approval: false, examples: ['格式化代码', '拼写修正'], enabled: true },
{ level: 'L1', name: '自动+通知', description: '自动执行后通知用户', requires_approval: false, examples: ['合并小PR', '回复邮件'], enabled: true },
{ level: 'L2', name: '建议+确认', description: '生成建议,需用户一次性确认', requires_approval: true, examples: ['修改API接口', '代码重构'], enabled: true },
{ level: 'L3', name: '详细审批', description: '高风险,需要详细说明和多重审批', requires_approval: true, examples: ['改数据库Schema'], enabled: false },
{ level: 'L4', name: '禁止自动', description: '始终需要人工操作', requires_approval: true, examples: ['删除生产数据'], enabled: false },
])
const prefLabels = {
security: '安全', performance: '性能', readability: '可读性', style: '风格',
structure: '结构', clarity: '清晰度', completeness: '完整度',
data_driven: '数据驱动', risk_averse: '风险厌恶', speed: '速度',
formality: '正式度', conciseness: '简洁度', responsiveness: '响应度',
}
const phaseLabel = computed(() => {
const map = { observe: '观察阶段', shadow: '影子模式', semi: '半自动模式', autonomous: '自主模式' }
return map[phase.value] || phase.value
})
const phaseDesc = computed(() => {
const map = {
observe: '正在学习你的行为模式...',
shadow: '生成建议但不执行,持续对比中',
semi: '低风险操作自动执行,高风险需确认',
autonomous: '大部分操作可自主完成',
}
return map[phase.value] || ''
})
const accuracyColor = computed(() => {
if (accuracy.value >= 90) return '#67c23a'
if (accuracy.value >= 70) return '#e6a23c'
return '#f56c6c'
})
const unlockedCount = computed(() => categories.filter((c) => c.unlocked).length)
const totalCategories = computed(() => categories.length)
const unlockPercent = computed(() => Math.round((unlockedCount.value / totalCategories.value) * 100))
const behaviorDistribution = computed(() => [
{ name: '代码评审', percent: 0, count: 0, color: '#409eff' },
{ name: '邮件处理', percent: 0, count: 0, color: '#67c23a' },
{ name: '文档审查', percent: 0, count: 0, color: '#e6a23c' },
{ name: '决策辅助', percent: 0, count: 0, color: '#f56c6c' },
])
const getThreshold = (key) => thresholds[key] || 0.85
const actionTagType = (action) => {
if (action === 'accept') return 'success'
if (action === 'modify') return 'warning'
if (action === 'reject') return 'danger'
return 'info'
}
const prefColor = (key) => {
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
const keys = Object.keys(preferences.value)
const idx = keys.indexOf(key)
return colors[idx % colors.length] || '#409eff'
}
const getSuggestionSummary = (row) => {
const s = row.shadow_suggestion || {}
const actions = s.suggested_actions || []
return actions.map((a) => a.action).join(', ') || '无建议'
}
async function refreshAll() {
loading.value = true
try {
await Promise.all([fetchAccuracy(), fetchBehaviors(), fetchLogs()])
} catch (e) {
console.error('刷新失败', e)
}
loading.value = false
}
async function fetchAccuracy() {
try {
const res = await api.get('/shadow/accuracy', { params: { days: days.value } })
const data = res.data || {}
accuracy.value = Math.round((data.average_accuracy || 0) * 100)
const byCat = data.by_category || {}
const unlocked = data.unlocked_categories || {}
categories.forEach((cat) => {
cat.accuracy = Math.round((byCat[cat.key] || 0) * 100)
cat.total = 0
cat.unlocked = !!unlocked[cat.key]
})
} catch (e) { /* API 开发中 */ }
}
async function fetchBehaviors() {
try {
const res = await api.get('/behaviors/stats')
const data = res.data || {}
totalBehaviors.value = data.total || 0
const byCat = data.by_category || {}
if (byCat.code_review) behaviorDistribution.value[0].count = byCat.code_review
if (byCat.email) behaviorDistribution.value[1].count = byCat.email
if (byCat.document) behaviorDistribution.value[2].count = byCat.document
if (byCat.decision) behaviorDistribution.value[3].count = byCat.decision
const max = Math.max(1, ...Object.values(byCat))
behaviorDistribution.value.forEach((d) => { d.percent = Math.round((d.count / max) * 100) })
preferences.value = data.preference_weights?.code_review || {}
} catch (e) { /* API 开发中 */ }
}
async function fetchLogs() {
try {
const res = await api.get('/shadow/comparisons', { params: { days: days.value, limit: 20 } })
logs.value = (res.data || []).slice(0, 20)
} catch (e) { /* API 开发中 */ }
}
function toggleAuthLevel(level, val) {
const item = authLevels.find((a) => a.level === level)
if (item) item.enabled = val
}
onMounted(() => {
refreshAll()
})
return {
loading, days, activeTab, phase, phaseLabel, phaseDesc,
accuracy, accuracyColor, totalBehaviors, categories, authLevels, logs,
preferences, prefLabels, prefColor, behaviorDistribution,
unlockedCount, totalCategories, unlockPercent,
getThreshold, actionTagType, getSuggestionSummary,
toggleAuthLevel, refreshAll,
}
},
})
</script>
<style scoped>
.digital-twin-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; }
.header-actions { display: flex; align-items: center; }
.stage-row { margin-top: 20px; }
.stage-card { text-align: center; }
.stage-badge {
display: inline-block; padding: 8px 24px; border-radius: 20px; font-size: 18px; font-weight: bold; margin-bottom: 12px;
}
.stage-badge.observe { background: #e6f0ff; color: #409eff; }
.stage-badge.shadow { background: #fdf6ec; color: #e6a23c; }
.stage-badge.semi { background: #e8f5e9; color: #67c23a; }
.stage-badge.autonomous { background: #e8f5e9; color: #2e7d32; }
.stat-mini-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; color: #409eff; margin-bottom: 4px; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
.main-content { margin-top: 20px; }
.ability-card { min-height: 220px; }
.ability-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.ability-stat { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 4px; }
.ability-threshold { text-align: center; font-size: 12px; color: #909399; margin-top: 12px; }
.auth-description { margin-bottom: 16px; color: #666; }
.suggestion-text { font-size: 13px; color: #666; }
.category-distribution { padding: 0; }
.dist-row { display: flex; align-items: center; margin-bottom: 16px; }
.dist-label { width: 80px; font-size: 13px; color: #606266; }
.dist-value { width: 40px; text-align: right; font-size: 14px; color: #303133; }
.preference-radar { padding: 0; }
.pref-row { margin-bottom: 14px; }
.pref-label { display: block; font-size: 13px; color: #606266; margin-bottom: 4px; }
</style>

View File

@@ -123,17 +123,155 @@
</div>
</div>
</el-card>
<!-- 用户交互面板 -->
<el-card class="interact-card" :class="{ collapsed: interactCollapsed }">
<template #header>
<div class="card-header-row">
<div class="header-left">
<span>交互对话</span>
<el-tag v-if="goal?.status" :type="statusTagType(goal?.status)" size="small" class="interact-status-tag">
{{ statusLabel(goal?.status) }}
</el-tag>
</div>
<div class="header-actions">
<el-button
v-if="goal?.status === 'active'"
size="small"
type="warning"
@click="handlePause"
:icon="VideoPause"
>暂停执行</el-button>
<el-button
v-if="goal?.status === 'paused'"
size="small"
type="success"
@click="handleResume"
:icon="VideoPlay"
>恢复执行</el-button>
<el-button
v-if="goal?.status === 'active' || goal?.status === 'paused'"
size="small"
type="danger"
@click="handleStop"
:icon="Close"
>停止</el-button>
<el-button size="small" @click="clearInteraction" :disabled="interactMessages.length === 0">清除对话</el-button>
<el-button size="small" text @click="interactCollapsed = !interactCollapsed">
<el-icon><ArrowUp v-if="!interactCollapsed" /><ArrowDown v-else /></el-icon>
</el-button>
</div>
</div>
</template>
<template v-if="!interactCollapsed">
<div
class="interact-messages"
ref="interactRef"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
:class="{ 'drag-over': dragOver }"
>
<div v-if="interactMessages.length === 0" class="interact-empty">
<p>暂无对话。Agent 执行任务后会在此显示问题和回复。</p>
<p class="interact-empty-hint">可直接拖放图片/文件到此区域上传</p>
</div>
<div
v-for="(msg, i) in interactMessages"
:key="i"
class="interact-msg"
:class="'interact-' + msg.role"
>
<div class="interact-avatar">
<span v-if="msg.role === 'agent'">🤖</span>
<span v-else>👤</span>
</div>
<div class="interact-body">
<div class="interact-meta">
<span class="interact-sender">{{ msg.role === 'agent' ? (msg.taskTitle || 'Agent') : '你' }}</span>
<span v-if="msg.attachments?.length" class="interact-attach-badge">
📎 {{ msg.attachments.length }} 个附件
</span>
</div>
<div class="interact-content" v-html="renderMsgMarkdown(msg.content)"></div>
<!-- 附件预览 -->
<div v-if="msg.attachments?.length" class="interact-attachments">
<div v-for="(att, ai) in msg.attachments" :key="ai" class="attach-chip">
<span v-if="isImageFile(att.filename)" class="attach-thumb">
<img :src="att.thumbUrl || getAttachUrl(att.relative_path)" @error="handleImgError($event)" />
</span>
<span class="attach-name">{{ att.filename }}</span>
<span class="attach-size">{{ formatFileSize(att.size) }}</span>
</div>
</div>
</div>
</div>
<div v-if="interacting" class="interact-msg interact-agent">
<div class="interact-avatar"><span>🤖</span></div>
<div class="interact-body">
<div class="thinking"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div>
</div>
</div>
<!-- 待发送文件预览 -->
<div v-if="pendingFiles.length" class="interact-pending-files">
<div v-for="(pf, idx) in pendingFiles" :key="idx" class="pending-file-chip">
<span v-if="pf._thumbUrl" class="pf-thumb"><img :src="pf._thumbUrl" /></span>
<span class="pf-name">{{ pf.filename }}</span>
<span class="pf-size">{{ formatFileSize(pf.size) }}</span>
<span class="pf-status">{{ pf._uploading ? '上传中...' : '✓' }}</span>
<el-button size="small" text type="danger" @click="removePendingFile(idx)">✕</el-button>
</div>
</div>
<div class="interact-input-row">
<el-button
class="attach-btn"
:icon="Paperclip"
@click="triggerFileInput"
:disabled="interacting"
title="上传图片/文件"
/>
<input
ref="fileInputRef"
type="file"
multiple
accept="image/*,.pdf,.txt,.md,.json,.csv,.xlsx,.xls,.docx,.doc,.zip,.rar"
style="display:none"
@change="handleFileSelect"
/>
<el-input
v-model="interactInput"
type="textarea"
:rows="2"
placeholder="回复 Agent 的问题,支持上传图片/文件..."
@keydown.enter.exact.prevent="sendInteraction"
:disabled="interacting"
/>
<el-button
type="primary"
@click="sendInteraction"
:loading="interacting"
:disabled="!interactInput.trim() && !pendingFiles.length"
class="interact-send-btn"
>
{{ interacting ? '处理中...' : '发送' }}
</el-button>
</div>
</template>
</el-card>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import MainLayout from '@/components/MainLayout.vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, User, WarningFilled } from '@element-plus/icons-vue'
import { ArrowLeft, User, WarningFilled, VideoPause, VideoPlay, Close, Paperclip, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { useGoalStore } from '@/stores/goal'
import api from '@/api'
const route = useRoute()
const router = useRouter()
@@ -148,6 +286,26 @@ const deleting = ref(false)
const viewMode = ref<'list' | 'gantt'>('list')
const lastUpdate = ref('')
// ── 交互对话 ──
interface InteractMessage {
role: 'agent' | 'user'
content: string
taskTitle?: string
attachments?: { relative_path: string; filename: string; size: number; thumbUrl?: string }[]
}
const interactMessages = ref<InteractMessage[]>([])
const interactInput = ref('')
const interacting = ref(false)
const interactRef = ref<HTMLElement | null>(null)
// ── 文件上传 ──
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<any[]>([])
const dragOver = ref(false)
// ── 交互面板工具栏 ──
const interactCollapsed = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const statusTagType = (s?: string) => {
@@ -228,11 +386,212 @@ const loadTaskTree = async () => {
const tree = await goalStore.fetchTaskTree(goalId.value)
tasks.value = tree?.tasks || []
lastUpdate.value = new Date().toLocaleTimeString()
// 自动将任务结果中的 Agent 问题加入交互消息
syncInteractMessages()
} finally {
loadingTasks.value = false
}
}
// ── 交互功能 ──
const syncInteractMessages = () => {
// 将已完成任务的 Agent 回复自动加入交互消息列表
const existing = new Set(interactMessages.value.map(m => m.content))
for (const t of tasks.value) {
if (t.status === 'completed' && t.result) {
const content = getResultContent(t.result)
if (content && !existing.has(content) && content.length > 10) {
interactMessages.value.push({
role: 'agent',
content,
taskTitle: t.title,
})
existing.add(content)
}
}
}
}
const getResultContent = (r: any): string => {
if (typeof r === 'string') {
try { const p = JSON.parse(r); return p.content || r } catch { return r }
}
if (typeof r === 'object' && r !== null) {
return r.content || r.output || ''
}
return ''
}
const sendInteraction = async () => {
const text = interactInput.value.trim()
if ((!text && !pendingFiles.value.length) || interacting.value) return
// 收集已上传的附件
const uploadedAtts = pendingFiles.value
.filter(f => f.relative_path && !f._error)
.map(f => ({
relative_path: f.relative_path,
filename: f.filename,
content_type: f.content_type || '',
size: f.size,
thumbUrl: f._thumbUrl,
}))
const userMsg: InteractMessage = {
role: 'user',
content: text || '(已上传文件)',
attachments: uploadedAtts.length ? uploadedAtts : undefined,
}
interactMessages.value.push(userMsg)
interactInput.value = ''
// 清空待发文件
for (const f of pendingFiles.value) {
if (f._thumbUrl) URL.revokeObjectURL(f._thumbUrl)
}
pendingFiles.value = []
interacting.value = true
scrollInteractBottom()
try {
const result = await goalStore.interactWithGoal(goalId.value, text)
interactMessages.value.push({
role: 'agent',
content: result.reply || '收到反馈,正在重新调整任务。',
taskTitle: result.actions_count > 0 ? `执行了 ${result.actions_count} 个操作` : undefined,
})
// 刷新任务列表
await loadTaskTree()
await loadGoal()
} catch (e: any) {
const errMsg = e.response?.data?.detail || e.message || '交互失败'
interactMessages.value.push({
role: 'agent',
content: `错误:${errMsg}`,
})
ElMessage.error(errMsg)
} finally {
interacting.value = false
scrollInteractBottom()
}
}
const renderMsgMarkdown = (text: string): string => {
if (!text) return ''
return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
}
const scrollInteractBottom = () => {
nextTick(() => {
if (interactRef.value) {
interactRef.value.scrollTop = interactRef.value.scrollHeight
}
})
}
// ── 文件上传功能 ──
function triggerFileInput() {
fileInputRef.value?.click()
}
function isImageFile(filename: string): boolean {
return /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(filename)
}
function formatFileSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function getAttachUrl(relativePath: string): string {
return `${api.defaults.baseURL || ''}/api/v1/uploads/preview/file?file_path=${encodeURIComponent(relativePath)}`
}
function handleImgError(e: Event) {
const el = e.target as HTMLImageElement
el.style.display = 'none'
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
await addPendingFiles(Array.from(input.files))
input.value = '' // reset
}
}
async function handleDrop(e: DragEvent) {
dragOver.value = false
if (e.dataTransfer?.files) {
await addPendingFiles(Array.from(e.dataTransfer.files))
}
}
async function addPendingFiles(fileList: File[]) {
for (const file of fileList) {
const entry: any = {
file,
filename: file.name,
size: file.size,
_uploading: true,
}
if (isImageFile(file.name)) {
entry._thumbUrl = URL.createObjectURL(file)
}
pendingFiles.value.push(entry)
// Upload to preview endpoint
try {
const fd = new FormData()
fd.append('file', file)
const res = await api.post('/api/v1/uploads/preview', fd)
const d = res.data
entry.relative_path = d.relative_path
entry.content_type = d.content_type
entry.size = d.size
entry._uploading = false
} catch {
entry._uploading = false
entry._error = true
ElMessage.warning(`"${file.name}" 上传失败`)
}
}
}
function removePendingFile(idx: number) {
const entry = pendingFiles.value[idx]
if (entry?._thumbUrl) URL.revokeObjectURL(entry._thumbUrl)
pendingFiles.value.splice(idx, 1)
}
// ── 交互增强功能 ──
function clearInteraction() {
interactMessages.value = []
}
async function handleStop() {
try {
await ElMessageBox.confirm('确定停止目标执行?可通过手动恢复继续。', '确认停止', { type: 'warning' })
await goalStore.pauseGoal(goalId.value)
ElMessage.success('目标已停止')
await loadGoal()
await loadTaskTree()
} catch { /* cancelled */ }
}
// 实时进度轮询(每10秒自动刷新)
const startPolling = () => {
if (pollTimer) return
@@ -243,6 +602,7 @@ const startPolling = () => {
const tree = await goalStore.fetchTaskTree(goalId.value)
tasks.value = tree?.tasks || []
lastUpdate.value = new Date().toLocaleTimeString()
syncInteractMessages()
} catch { /* silent */ }
}
}, 10000)
@@ -403,4 +763,94 @@ onUnmounted(() => {
.gantt-bar-failed { background: #f56c6c; }
.gantt-bar-cancelled { background: #c0c4cc; }
.gantt-bar-awaiting_approval { background: #e6a23c; }
/* 交互面板 */
.interact-card { margin-bottom: 16px; }
.interact-card.collapsed .el-card__body { display: none; }
.header-left { display: flex; align-items: center; gap: 8px; }
.interact-status-tag { margin-left: 8px; }
.interact-messages {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
margin-bottom: 8px;
transition: background 0.2s;
}
.interact-messages.drag-over {
background: #ecf5ff;
border: 2px dashed #409eff;
border-radius: 8px;
}
.interact-empty {
text-align: center;
color: #c0c4cc;
font-size: 13px;
padding: 24px 0;
}
.interact-empty-hint { color: #dcdfe6; font-size: 12px; margin-top: 4px; }
.interact-msg { display: flex; gap: 10px; max-width: 95%; }
.interact-agent { align-self: flex-start; }
.interact-user { align-self: flex-end; flex-direction: row-reverse; }
.interact-avatar { flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; font-size: 18px; border-radius: 50%; background: #f0f2f5; }
.interact-user .interact-avatar { background: #ecf5ff; }
.interact-body { flex: 1; min-width: 0; }
.interact-meta { margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.interact-sender { font-size: 12px; color: #909399; font-weight: 500; }
.interact-attach-badge { font-size: 11px; color: #67c23a; }
.interact-content { font-size: 14px; line-height: 1.6; background: #f5f7fa; padding: 10px 14px; border-radius: 8px; word-break: break-word; }
.interact-user .interact-content { background: #ecf5ff; }
.interact-content :deep(pre) { background: #e8eaed; padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 12px; }
.interact-content :deep(code) { background: #e8eaed; padding: 2px 4px; border-radius: 3px; font-size: 13px; }
.interact-content :deep(table) { border-collapse: collapse; margin: 8px 0; }
.interact-content :deep(th), .interact-content :deep(td) { border: 1px solid #dcdfe6; padding: 4px 8px; font-size: 13px; }
.interact-content :deep(th) { background: #e8eaed; }
/* 附件展示 */
.interact-attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.attach-chip {
display: flex; align-items: center; gap: 6px;
background: #fff; border: 1px solid #e4e7ed; border-radius: 6px;
padding: 4px 8px; font-size: 12px;
}
.attach-thumb img { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; }
.attach-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attach-size { color: #c0c4cc; flex-shrink: 0; }
/* 待发送文件 */
.interact-pending-files {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 6px 0; margin-bottom: 4px;
}
.pending-file-chip {
display: flex; align-items: center; gap: 6px;
background: #ecf5ff; border: 1px solid #b3d8ff; border-radius: 6px;
padding: 4px 8px; font-size: 12px;
}
.pf-thumb img { width: 28px; height: 28px; object-fit: cover; border-radius: 3px; }
.pf-name { max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pf-size { color: #909399; flex-shrink: 0; }
.pf-status { color: #67c23a; flex-shrink: 0; font-size: 11px; }
/* 输入行 */
.interact-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.interact-input-row .el-textarea { flex: 1; }
.interact-send-btn { min-width: 80px; height: 40px; }
.attach-btn {
height: 40px; width: 40px; padding: 0;
display: flex; align-items: center; justify-content: center;
}
/* Thinking dots */
.thinking { display: flex; gap: 4px; padding: 8px 0; }
.dot { width: 8px; height: 8px; background: #909399; border-radius: 50%; animation: bounce 1.4s infinite ease-in-out; }
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce { 0%,80%,100% { transform: scale(0); } 40% { transform: scale(1); } }
</style>

View File

@@ -0,0 +1,455 @@
<template>
<MainLayout>
<div class="knowledge-dashboard-page">
<el-card class="header-card">
<div class="page-header">
<h2>知识进化仪表盘</h2>
<div class="header-actions">
<el-select v-model="days" style="width: 140px; margin-right: 12px">
<el-option :value="1" label="最近1天" />
<el-option :value="7" label="最近7天" />
<el-option :value="14" label="最近14天" />
<el-option :value="30" label="最近30天" />
</el-select>
<el-button @click="refreshAll" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
</el-card>
<!-- 概览卡片 -->
<el-row :gutter="20" class="overview-row">
<el-col :span="4" v-for="item in overviewItems" :key="item.key">
<el-card class="overview-card" shadow="hover">
<div class="ov-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="ov-label">{{ item.label }}</div>
<div class="ov-trend" v-if="item.trend">
<el-icon :color="item.trend > 0 ? '#67c23a' : '#f56c6c'">
<component :is="item.trend > 0 ? 'CaretTop' : 'CaretBottom'" />
</el-icon>
<span>{{ Math.abs(item.trend) }}%</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 知识增长趋势 + 类别分布 -->
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="16">
<el-card>
<template #header>
<h4>知识条目增长趋势</h4>
</template>
<div class="trend-chart">
<div
v-for="(item, idx) in trendData"
:key="idx"
class="trend-bar-wrapper"
>
<div class="trend-bar-value">{{ item.count }}</div>
<div
class="trend-bar"
:style="{ height: maxTrend ? `${(item.count / maxTrend) * 180}px` : '0px' }"
:title="`${item.date}: ${item.count}条`"
></div>
<div class="trend-bar-label">{{ item.date.slice(-2) }}日</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<template #header>
<h4>知识类别分布</h4>
</template>
<div class="category-pie">
<div v-for="item in categoryDistribution" :key="item.name" class="cat-row">
<span class="cat-dot" :style="{ background: item.color }"></span>
<span class="cat-label">{{ item.label }}</span>
<span class="cat-value">{{ item.count }}</span>
<el-progress
:percentage="item.percent"
:color="item.color"
:stroke-width="8"
style="flex: 1; margin-left: 8px"
/>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 瓶颈 & 优化 / 知识库 -->
<el-card style="margin-top: 20px">
<el-tabs v-model="activeTab">
<!-- 工作流瓶颈 -->
<el-tab-pane label="工作流瓶颈" name="bottlenecks">
<div v-if="bottlenecks.length === 0" class="empty-hint">暂无瓶颈检测结果</div>
<el-table v-else :data="bottlenecks" style="width: 100%">
<el-table-column prop="node_type" label="节点类型" width="140" />
<el-table-column label="P95耗时" width="120">
<template #default="{ row }">{{ row.p95_ms }}ms</template>
</el-table-column>
<el-table-column label="错误率" width="100">
<template #default="{ row }">
<el-tag :type="row.error_rate > 0.2 ? 'danger' : row.error_rate > 0.05 ? 'warning' : 'success'" size="small">
{{ (row.error_rate * 100).toFixed(1) }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="严重程度" width="100">
<template #default="{ row }">
<el-tag :type="severityTag(row.severity)" size="small">{{ row.severity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.is_bottleneck" type="danger" size="small">瓶颈</el-tag>
<el-tag v-else-if="row.is_problematic" type="warning" size="small">问题</el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="优化建议" min-width="300">
<template #default="{ row }">
<div v-if="optimizations[row.node_type]">
<el-tag
v-for="(change, ci) in (optimizations[row.node_type].changes || [])"
:key="ci"
size="small"
style="margin: 2px"
>
{{ change.description?.slice(0, 40) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button v-if="optimizations[row.node_type]" size="small" type="primary" @click="showOptimization(row)">
查看方案
</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 知识条目 -->
<el-tab-pane label="知识条目" name="knowledge">
<el-row :gutter="12" style="margin-bottom: 16px">
<el-col :span="6">
<el-input v-model="searchQuery" placeholder="搜索知识..." clearable />
</el-col>
<el-col :span="4">
<el-select v-model="filterCategory" placeholder="类别筛选" clearable>
<el-option value="bug_fix" label="Bug修复" />
<el-option value="best_practice" label="最佳实践" />
<el-option value="workaround" label="变通方案" />
<el-option value="optimization" label="优化技巧" />
<el-option value="insight" label="经验洞察" />
</el-select>
</el-col>
</el-row>
<el-table :data="filteredKnowledge" style="width: 100%">
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="类别" width="100">
<template #default="{ row }">
<el-tag :type="catTag(row.category)" size="small">
{{ catLabel(row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="置信度" width="100">
<template #default="{ row }">
<el-progress :percentage="Math.round((row.confidence || 0) * 100)" :stroke-width="6" />
</template>
</el-table-column>
<el-table-column label="引用次数" width="100">
<template #default="{ row }">{{ row.retrieval_count || 0 }}</template>
</el-table-column>
<el-table-column label="成功率" width="100">
<template #default="{ row }">
<el-tag :type="(row.success_rate || 0) > 0.8 ? 'success' : 'warning'" size="small">
{{ ((row.success_rate || 0) * 100).toFixed(0) }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="140">
<template #default="{ row }">{{ row.source_agent_name || '手动录入' }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 反馈分析 -->
<el-tab-pane label="反馈分析" name="feedback">
<div v-if="!feedbackData.total_feedback" class="empty-hint">暂无反馈数据</div>
<template v-else>
<el-row :gutter="20" style="margin-bottom: 20px">
<el-col :span="6">
<el-card class="fb-stat" shadow="hover">
<div class="fb-value">{{ feedbackData.total_feedback }}</div>
<div class="fb-label">总反馈数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="fb-stat" shadow="hover">
<div class="fb-value" style="color: #f56c6c">{{ (feedbackData.overall_negative_rate * 100).toFixed(1) }}%</div>
<div class="fb-label">负面反馈率</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="fb-stat">
<div class="fb-label" style="margin-bottom: 8px">信号分布</div>
<div class="signal-bars">
<div v-for="(count, signal) in (feedbackData.signal_distribution || {})" :key="signal" class="signal-bar-item">
<el-tag size="small">{{ signalLabels[signal] || signal }}</el-tag>
<el-progress
:percentage="totalFeedback ? Math.round((count / totalFeedback) * 100) : 0"
:stroke-width="10" :color="signalColor(signal)"
style="flex: 1; margin-left: 8px"
/>
<span>{{ count }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<h4>策略建议</h4>
<div v-for="(advice, idx) in (feedbackData.strategy_advice || [])" :key="idx" class="advice-card">
<el-card shadow="hover">
<div class="advice-header">
<el-tag :type="adviceType(advice.type)" size="small">{{ advice.type }}</el-tag>
<span class="advice-reason">{{ advice.reason }}</span>
</div>
<div class="advice-action">
<strong>建议操作:</strong> {{ advice.action }}
</div>
</el-card>
</div>
</template>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 优化方案对话框 -->
<el-dialog v-model="optDialogVisible" title="优化方案详情" width="600px">
<div v-if="selectedOpt">
<el-descriptions :column="2" border>
<el-descriptions-item label="节点类型">{{ selectedOpt.node_type }}</el-descriptions-item>
<el-descriptions-item label="严重程度">
<el-tag :type="severityTag(selectedOpt.severity)">{{ selectedOpt.severity }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前P95" :span="2">{{ selectedOpt.p95_ms }}ms</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 16px">
<h4>优化变更</h4>
<div v-for="(c, ci) in (selectedOpt.changes || optimizations[selectedOpt.node_type]?.changes || [])" :key="ci" style="margin-bottom: 12px">
<el-card shadow="never">
<p><strong>{{ c.type }}</strong>: {{ c.description }}</p>
<p>预估提升: {{ c.estimated_improvement }}</p>
</el-card>
</div>
</div>
</div>
<template #footer>
<el-button @click="optDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="applyOptimization">应用优化</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script>
import { defineComponent, ref, reactive, onMounted, computed } from 'vue'
import MainLayout from '@/layouts/MainLayout.vue'
import api from '@/api'
export default defineComponent({
name: 'KnowledgeDashboard',
components: { MainLayout },
setup() {
const loading = ref(false)
const days = ref(7)
const activeTab = ref('bottlenecks')
const bottlenecks = ref([])
const optimizations = reactive({})
const knowledgeEntries = ref([])
const feedbackData = ref({})
const trendData = ref([])
const searchQuery = ref('')
const filterCategory = ref('')
const optDialogVisible = ref(false)
const selectedOpt = ref(null)
const signalLabels = {
thumbs_down: '点踩', manual_edit: '手动修改', retry_command: '要求重试', reject_approval: '审批驳回',
}
const catLabels = {
bug_fix: 'Bug修复', best_practice: '最佳实践', workaround: '变通方案', optimization: '优化', insight: '洞察',
}
const catColors = {
bug_fix: '#f56c6c', best_practice: '#67c23a', workaround: '#e6a23c', optimization: '#409eff', insight: '#909399',
}
const overviewItems = computed(() => [
{ key: 'knowledge', value: knowledgeEntries.value.length, label: '知识条目', color: '#409eff', trend: 12 },
{ key: 'bottlenecks', value: bottlenecks.value.filter((b) => b.is_bottleneck).length, label: '瓶颈节点', color: '#f56c6c', trend: -3 },
{ key: 'optimizations', value: Object.keys(optimizations).length, label: '优化方案', color: '#67c23a', trend: 5 },
{ key: 'feedback', value: feedbackData.value.total_feedback || 0, label: '反馈信号', color: '#e6a23c', trend: null },
{ key: 'success_rate', value: successRate.value, label: '成功率趋势', color: '#409eff', trend: 8 },
])
const successRate = computed(() => {
const entries = knowledgeEntries.value
if (!entries.length) return '--'
const avg = entries.reduce((s, e) => s + (e.success_rate || 0), 0) / entries.length
return (avg * 100).toFixed(1) + '%'
})
const maxTrend = computed(() => Math.max(1, ...trendData.value.map((d) => d.count)))
const totalFeedback = computed(() => feedbackData.value.total_feedback || 0)
const categoryDistribution = computed(() => {
const dist = {}
knowledgeEntries.value.forEach((e) => {
dist[e.category] = (dist[e.category] || 0) + 1
})
const total = Math.max(1, Object.values(dist).reduce((a, b) => a + b, 0))
return Object.entries(dist).map(([cat, count]) => ({
name: cat, label: catLabels[cat] || cat, count, color: catColors[cat] || '#909399',
percent: Math.round((count / total) * 100),
}))
})
const filteredKnowledge = computed(() => {
let list = knowledgeEntries.value
if (filterCategory.value) {
list = list.filter((e) => e.category === filterCategory.value)
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
list = list.filter((e) =>
(e.title || '').toLowerCase().includes(q) ||
(e.situation || '').toLowerCase().includes(q)
)
}
return list
})
function severityTag(s) { return s === 'high' ? 'danger' : s === 'medium' ? 'warning' : 'success' }
function catTag(c) { return { bug_fix: 'danger', best_practice: 'success', workaround: 'warning', optimization: '', insight: 'info' }[c] || '' }
function catLabel(c) { return catLabels[c] || c }
function signalColor(s) {
const m = { thumbs_down: '#f56c6c', manual_edit: '#e6a23c', retry_command: '#409eff', reject_approval: '#909399' }
return m[s] || '#909399'
}
function adviceType(t) {
return { adjust_temperature: 'warning', enhance_prompt: '', suggest_review: 'info' }[t] || ''
}
async function refreshAll() {
loading.value = true
try {
await Promise.all([
fetchBottlenecks(), fetchKnowledge(), fetchFeedback(), fetchTrend(),
])
} catch (e) { console.error('刷新失败', e) }
loading.value = false
}
async function fetchBottlenecks() {
try {
const res = await api.get('/bottlenecks/analysis', { params: { hours: days.value * 24 } })
const data = res.data || {}
bottlenecks.value = data.bottlenecks || []
const recs = data.recommendations || []
recs.forEach((r) => {
optimizations[r.node_type] = r
})
} catch (e) { /* API 开发中 */ }
}
async function fetchKnowledge() {
try {
const res = await api.get('/knowledge/entries', { params: { days: days.value, limit: 50 } })
knowledgeEntries.value = (res.data || []).slice(0, 50)
} catch (e) { /* API 开发中 */ }
}
async function fetchFeedback() {
try {
const res = await api.get('/feedback/analysis', { params: { days: days.value } })
feedbackData.value = res.data || {}
} catch (e) { /* API 开发中 */ }
}
async function fetchTrend() {
try {
const res = await api.get('/knowledge/trend', { params: { days: days.value } })
trendData.value = res.data || []
} catch (e) { /* API 开发中 */ }
}
function showOptimization(row) {
selectedOpt.value = row
optDialogVisible.value = true
}
function applyOptimization() {
optDialogVisible.value = false
// 对接 workflow 优化 API
}
onMounted(() => { refreshAll() })
return {
loading, days, activeTab, bottlenecks, optimizations, knowledgeEntries,
feedbackData, trendData, searchQuery, filterCategory,
optDialogVisible, selectedOpt, signalLabels, catLabels,
overviewItems, successRate, maxTrend, totalFeedback,
categoryDistribution, filteredKnowledge,
severityTag, catTag, catLabel, signalColor, adviceType,
showOptimization, applyOptimization, refreshAll,
}
},
})
</script>
<style scoped>
.knowledge-dashboard-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; }
.header-actions { display: flex; align-items: center; }
.overview-row { margin-top: 20px; }
.overview-card { text-align: center; }
.ov-value { font-size: 28px; font-weight: bold; margin-bottom: 4px; }
.ov-label { font-size: 13px; color: #909399; }
.ov-trend { font-size: 12px; margin-top: 4px; display: flex; align-items: center; justify-content: center; gap: 2px; }
.trend-chart { display: flex; align-items: flex-end; justify-content: space-around; height: 240px; padding: 0 10px; }
.trend-bar-wrapper { display: flex; flex-direction: column; align-items: center; flex: 1; }
.trend-bar-value { font-size: 11px; color: #909399; margin-bottom: 4px; }
.trend-bar { width: 28px; background: linear-gradient(180deg, #409eff, #79bbff); border-radius: 4px 4px 0 0; min-height: 4px; transition: height 0.3s; }
.trend-bar-label { font-size: 11px; color: #606266; margin-top: 6px; }
.category-pie { padding: 0; }
.cat-row { display: flex; align-items: center; margin-bottom: 14px; }
.cat-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; flex-shrink: 0; }
.cat-label { width: 60px; font-size: 13px; color: #606266; }
.cat-value { width: 24px; text-align: right; font-size: 14px; color: #303133; margin-right: 8px; }
.empty-hint { text-align: center; color: #909399; padding: 40px; }
.fb-stat { text-align: center; }
.fb-value { font-size: 28px; font-weight: bold; color: #409eff; }
.fb-label { font-size: 13px; color: #909399; }
.signal-bars { display: flex; flex-direction: column; gap: 10px; }
.signal-bar-item { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.advice-card { margin-bottom: 12px; }
.advice-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.advice-reason { color: #606266; }
.advice-action { color: #303133; }
</style>