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:
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal 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
37
frontend/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
// XSS 防护: 先转义 HTML 特殊字符,再对安全模式做 Markdown 渲染
|
||||
return text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
|
||||
@@ -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>
|
||||
|
||||
406
frontend/src/views/DigitalTwin.vue
Normal file
406
frontend/src/views/DigitalTwin.vue
Normal 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>
|
||||
@@ -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, '<').replace(/>/g, '>')
|
||||
.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>
|
||||
|
||||
455
frontend/src/views/KnowledgeDashboard.vue
Normal file
455
frontend/src/views/KnowledgeDashboard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user