Files
xk-ai-agent/view/agent.html
2026-08-14 21:50:48 +08:00

2000 lines
112 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent 控制台 · 萧康云医 TCM Agent</title>
<!-- 内联 SVG favicon避免浏览器请求 /favicon.ico 被鉴权中间件拦成 401 噪音 -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234361ee'/%3E%3Ctext x='16' y='22' font-size='16' text-anchor='middle' fill='white' font-family='sans-serif'%3E%E5%8C%BB%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/dist/index.css" />
<script src="https://unpkg.com/vue@3.4.38/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus@2.7.6/dist/index.full.min.js"></script>
<script src="https://unpkg.com/element-plus@2.7.6/dist/locale/zh-cn.min.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
<script src="https://unpkg.com/echarts@5.5.0/dist/echarts.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/theme-chalk/dark/css-vars.css" />
<style>
/* ============================================================
* 主题系统:与 KB 页(/kb/view共用同一套变量与 localStorage keykb_theme
* 亮色 = 冷蓝紫,暗色 = 琥珀橙,保持两个面板视觉一致
* ============================================================ */
:root,
[data-theme="light"] {
--bg-gradient: radial-gradient(ellipse at top left, #e8edff 0%, #f5f7ff 35%, #fafbff 100%);
--text-primary: #0f1729;
--text-secondary: #4e5969;
--text-tertiary: #86909c;
--glass-bg: rgba(255, 255, 255, 0.65);
--glass-bg-strong: rgba(255, 255, 255, 0.85);
--glass-border-subtle: rgba(64, 80, 180, 0.08);
--glass-blur: saturate(180%) blur(20px);
--glass-shadow: 0 4px 24px rgba(64, 80, 180, 0.06);
--accent: #4361ee;
--accent-soft: rgba(67, 97, 238, 0.12);
--accent-grad: linear-gradient(135deg, #4361ee 0%, #7209b7 100%);
--success: #009a55;
--warning: #d46b08;
--danger: #f5222d;
--border: rgba(64, 80, 180, 0.12);
--log-bg: rgba(15, 23, 41, 0.03);
}
[data-theme="dark"] {
--bg-gradient: radial-gradient(ellipse at top left, #2a1f15 0%, #181210 35%, #0e0a08 100%);
--text-primary: #fdf6e3;
--text-secondary: #d4c5a9;
--text-tertiary: #998871;
--glass-bg: rgba(36, 28, 22, 0.55);
--glass-bg-strong: rgba(52, 40, 30, 0.78);
--glass-border-subtle: rgba(255, 180, 100, 0.05);
--glass-blur: saturate(150%) blur(20px);
--glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
--accent: #f59e0b;
--accent-soft: rgba(245, 158, 11, 0.18);
--accent-grad: linear-gradient(135deg, #f59e0b 0%, #dc2626 100%);
--success: #84cc16;
--warning: #fb923c;
--danger: #ef4444;
--border: rgba(255, 180, 100, 0.15);
--log-bg: rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; min-height: 100%;
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
transition: background 0.3s ease, color 0.3s ease;
color-scheme: light;
}
[data-theme="dark"] body { color-scheme: dark; }
body { background: var(--bg-gradient); background-attachment: fixed; }
#app { min-height: 100vh; }
[v-cloak] { display: none; }
/* ============================================================
* 后台布局:左侧菜单 + 顶栏 + 内容区
* ============================================================ */
.layout { display: flex; min-height: 100vh; }
/* 左侧菜单栏(毛玻璃) */
.sidebar {
width: 210px; flex-shrink: 0;
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-right: 1px solid var(--glass-border-subtle);
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
transition: width 0.25s ease;
}
.sidebar.collapsed { width: 64px; }
.sidebar-logo {
padding: 18px 16px 14px;
border-bottom: 1px solid var(--glass-border-subtle);
overflow: hidden; white-space: nowrap;
}
.sidebar-logo .logo-title {
font-size: 16px; font-weight: 700;
background: var(--accent-grad);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-logo .logo-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
.sidebar-menu { flex: 1; overflow-y: auto; padding: 8px 0; }
.menu-item {
display: flex; align-items: center; gap: 10px;
margin: 2px 10px; padding: 10px 14px;
border-radius: 10px; cursor: pointer;
color: var(--text-secondary);
white-space: nowrap; overflow: hidden;
transition: background 0.15s ease, color 0.15s ease;
}
.menu-item:hover { background: var(--accent-soft); color: var(--text-primary); }
.menu-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.menu-item .el-icon { font-size: 17px; flex-shrink: 0; }
.sidebar.collapsed .menu-item { justify-content: center; padding: 10px 0; }
.sidebar.collapsed .menu-label, .sidebar.collapsed .logo-sub { display: none; }
.sidebar.collapsed .logo-title { font-size: 13px; }
.sidebar-footer {
padding: 10px; border-top: 1px solid var(--glass-border-subtle);
display: flex; justify-content: center;
}
/* 右侧内容 */
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: 1px solid var(--glass-border-subtle);
padding: 0 28px; height: 58px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
}
.topbar-title { font-size: 16px; font-weight: 600; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.main { padding: 22px 28px 48px; max-width: 1280px; width: 100%; margin: 0 auto; }
/* 玻璃卡片 */
.glass-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border-subtle);
border-radius: 16px;
box-shadow: var(--glass-shadow);
padding: 20px 24px;
}
/* 统计卡片 */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.stat-card { text-align: center; padding: 24px 16px; }
.stat-value { font-size: 30px; font-weight: 700; color: var(--accent); }
.stat-value.ok { color: var(--success); }
.stat-value.bad { color: var(--danger); }
.stat-value.warn { color: var(--warning); }
.stat-label { margin-top: 6px; color: var(--text-tertiary); font-size: 13px; }
/* 首页状态横幅 */
.home-banner {
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
margin-bottom: 16px; padding: 18px 24px;
}
.banner-item { display: flex; align-items: center; gap: 10px; }
.banner-item .el-icon { font-size: 22px; color: var(--accent); }
.banner-item .b-label { font-size: 12px; color: var(--text-tertiary); }
.banner-item .b-value { font-size: 15px; font-weight: 600; }
/* 图表容器 */
.chart-box { width: 100%; height: 280px; }
/* 步骤小圆点 */
.step-dots { display: inline-flex; gap: 4px; align-items: center; }
.step-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.step-dot.s1 { background: var(--success); }
.step-dot.s2 { background: var(--danger); }
.step-dot.s0 { background: var(--text-tertiary); }
/* 时间线 detail 块 */
.step-detail {
margin-top: 6px; padding: 10px 12px;
background: var(--glass-bg-strong);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px; line-height: 1.7;
white-space: pre-wrap; word-break: break-all;
color: var(--text-secondary);
max-height: 320px; overflow: auto;
}
.step-meta { color: var(--text-tertiary); font-size: 12px; margin-top: 2px; }
.timeline-card-title { font-weight: 600; display: flex; align-items: center; gap: 8px; }
/* 配置项两列展示 */
.cfg-row { display: flex; padding: 9px 0; border-bottom: 1px dashed var(--border); }
.cfg-row:last-child { border-bottom: none; }
.cfg-key { width: 200px; flex-shrink: 0; color: var(--text-tertiary); }
.cfg-val { color: var(--text-primary); word-break: break-all; font-family: Consolas, Monaco, monospace; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.muted { color: var(--text-tertiary); font-size: 12px; }
.section-title { font-weight: 600; margin-bottom: 12px; }
/* 实时日志滚动区 */
.log-viewer {
background: var(--log-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
height: 560px; overflow-y: auto;
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 12px; line-height: 1.8;
}
.log-line { white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); }
.log-line .tag-http { color: var(--accent); font-weight: 600; }
.log-line .tag-err { color: var(--danger); font-weight: 600; }
.log-line .tag-warn { color: var(--warning); font-weight: 600; }
.log-line .tag-mod { color: var(--success); font-weight: 600; }
.kw-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.kb-doc {
padding: 12px 14px; margin-bottom: 10px;
background: var(--glass-bg-strong);
border: 1px solid var(--border); border-radius: 10px;
font-size: 13px; line-height: 1.7;
white-space: pre-wrap; word-break: break-all;
color: var(--text-secondary);
max-height: 220px; overflow: auto;
}
/* Element Plus 组件融入玻璃风格 */
.el-table { --el-table-bg-color: transparent; --el-table-tr-bg-color: transparent; background: transparent; }
.el-drawer__body { padding-top: 8px; }
.el-collapse { --el-collapse-header-bg-color: transparent; --el-collapse-content-bg-color: transparent; border: none; }
/* ============================================================
* 知识库模块样式(自 view/index.html 移植,保持视觉一致)
* ============================================================ */
.menu-divider {
margin: 8px 20px; border-top: 1px solid var(--glass-border-subtle);
}
.menu-group-title {
padding: 6px 24px 4px; font-size: 11px; color: var(--text-tertiary);
white-space: nowrap; overflow: hidden;
}
.sidebar.collapsed .menu-group-title { display: none; }
.lib-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.lib-card {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border: 1px solid var(--glass-border-subtle);
box-shadow: var(--glass-shadow);
border-radius: 14px;
padding: 20px;
cursor: pointer;
transition: all 0.25s ease;
}
.lib-card:hover {
transform: translateY(-2px);
background: var(--glass-bg-strong);
border-color: var(--border);
}
.lib-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.lib-icon-wrap {
width: 36px; height: 36px;
background: var(--accent-soft);
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
color: var(--accent);
}
.lib-title {
font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lib-desc {
font-size: 12px; color: var(--text-tertiary); line-height: 1.5; height: 36px;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.lib-stats {
display: flex; gap: 20px; margin-top: 14px; padding-top: 14px;
border-top: 1px solid var(--glass-border-subtle);
}
.lib-stat .num { font-size: 18px; font-weight: 600; color: var(--text-primary); line-height: 1; }
.lib-stat .lbl { font-size: 11px; color: var(--text-tertiary); margin-top: 3px; }
.lib-card-footer { margin-top: 12px; display: flex; justify-content: space-between; align-items: center; }
.source-pill {
font-size: 10px; padding: 2px 8px; border-radius: 4px;
background: var(--accent-soft); color: var(--text-secondary);
}
.empty-state { text-align: center; padding: 60px 20px; }
.empty-state .icon-wrap {
width: 72px; height: 72px; margin: 0 auto 16px;
background: var(--accent-soft); border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: var(--text-tertiary);
}
.empty-state h3 { margin: 0 0 6px; font-size: 15px; color: var(--text-primary); font-weight: 600; }
.empty-state p { margin: 0; color: var(--text-tertiary); font-size: 13px; }
.doc-list { display: flex; flex-direction: column; gap: 10px; }
.doc-item {
background: var(--glass-bg);
border: 1px solid var(--glass-border-subtle);
border-radius: 11px;
padding: 14px 18px;
display: flex; align-items: center; justify-content: space-between;
transition: all 0.2s ease;
}
.doc-item:hover { background: var(--glass-bg-strong); border-color: var(--border); }
.doc-info { flex: 1; min-width: 0; }
.doc-title {
font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.doc-meta { font-size: 12px; color: var(--text-tertiary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.drop-zone {
background: var(--glass-bg);
border: 1.5px dashed var(--border);
border-radius: 12px;
padding: 32px 20px;
text-align: center; cursor: pointer;
transition: all 0.25s ease;
margin-bottom: 16px;
}
.drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: var(--accent-soft); }
.drop-title { font-size: 14px; font-weight: 500; color: var(--text-primary); margin: 8px 0 4px; }
.drop-tip { font-size: 12px; color: var(--text-tertiary); }
.result-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border-subtle);
border-left: 3px solid var(--accent);
border-radius: 11px;
padding: 16px 18px;
margin-bottom: 10px;
}
.result-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.result-content {
font-size: 13px; color: var(--text-secondary); line-height: 1.65;
white-space: pre-wrap; word-break: break-word;
max-height: 180px; overflow: auto;
background: var(--log-bg);
padding: 10px 12px; border-radius: 7px;
margin-top: 8px;
}
.score-bar { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
.chunk-item { padding: 14px; background: var(--log-bg); border-radius: 9px; margin-bottom: 8px; }
.chunk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.chunk-idx {
display: inline-flex; align-items: center; justify-content: center;
min-width: 22px; height: 22px;
background: var(--accent); color: white;
border-radius: 50%; font-size: 10px; font-weight: 600;
}
.chunk-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.chunk-content {
font-size: 12px; color: var(--text-secondary); line-height: 1.65;
white-space: pre-wrap; word-break: break-word;
background: var(--glass-bg-strong);
padding: 8px 10px; border-radius: 6px;
}
.chunk-related {
margin-top: 6px; padding: 6px 10px;
background: var(--accent-soft); border-radius: 5px;
font-size: 11px; color: var(--accent);
}
.skeleton-card {
height: 180px; border-radius: 14px;
background: linear-gradient(90deg, var(--glass-bg) 25%, var(--glass-bg-strong) 37%, var(--glass-bg) 63%);
background-size: 400% 100%;
animation: skeleton-loading 1.4s ease infinite;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
/* 窄屏:侧栏自动收窄为纯图标 */
@media (max-width: 768px) {
.sidebar { width: 64px; }
.sidebar .menu-label, .sidebar .logo-sub { display: none; }
.sidebar .menu-item { justify-content: center; padding: 10px 0; }
.main { padding: 16px 12px 40px; }
}
</style>
</head>
<body>
<div id="app" v-cloak>
<!-- 口令弹窗(与 KB 页共用口令与 localStorage key输一次两个面板通用 -->
<el-dialog v-model="pwdDialog.visible" title="管理口令" width="360px"
:close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<el-input v-model="pwdInput" type="password" placeholder="请输入管理口令(与知识库后台相同)"
show-password @keyup.enter="submitPwd"></el-input>
<template #footer>
<el-button type="primary" :loading="pwdDialog.loading" @click="submitPwd" style="width: 100%">进入控制台</el-button>
</template>
</el-dialog>
<div class="layout">
<!-- ================= 左侧菜单 ================= -->
<div class="sidebar" :class="{ collapsed: menuCollapsed }">
<div class="sidebar-logo">
<div class="logo-title">TCM Agent</div>
<div class="logo-sub">萧康云医 · Agent 控制台</div>
</div>
<div class="sidebar-menu">
<template v-for="(m, mi) in menus" :key="m.key || 'sep' + mi">
<div v-if="m.divider" class="menu-divider"></div>
<div v-else-if="m.group" class="menu-group-title">{{ m.group }}</div>
<div v-else class="menu-item"
:class="{ active: activePage === m.key }" @click="switchPage(m.key)">
<el-icon><component :is="m.icon"></component></el-icon>
<span class="menu-label">{{ m.label }}</span>
</div>
</template>
</div>
<div class="sidebar-footer">
<el-button text circle @click="toggleCollapse">
<!-- in-DOM 模板不能用自闭合组件标签(浏览器会把后续元素吞成子节点),必须显式闭合 -->
<el-icon><Expand v-if="menuCollapsed"></Expand><Fold v-else></Fold></el-icon>
</el-button>
</div>
</div>
<!-- ================= 右侧内容 ================= -->
<div class="content">
<div class="topbar">
<div class="topbar-title">{{ currentMenuLabel }}</div>
<div class="topbar-right">
<el-tooltip content="运行记录/统计/首页每 5 秒自动拉取" placement="bottom">
<el-switch v-model="autoRefresh" size="small" active-text="自动刷新"
@change="saveAutoRefresh"></el-switch>
</el-tooltip>
<el-tooltip content="切换亮/暗主题" placement="bottom">
<el-button size="small" circle @click="toggleTheme">
<el-icon><Moon v-if="!isDark"></Moon><Sunny v-else></Sunny></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="退出登录(清除口令)" placement="bottom">
<el-button size="small" circle @click="logout">
<el-icon><SwitchButton></SwitchButton></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<div class="main">
<!-- ================= 首页 ================= -->
<div v-show="activePage === 'home'">
<!-- 状态横幅:服务健康 + 运行时长 + 生效模型 -->
<div class="glass-card home-banner">
<div class="banner-item">
<el-icon :style="{ color: healthOK ? 'var(--success)' : 'var(--danger)' }"><CircleCheckFilled v-if="healthOK"></CircleCheckFilled><CircleCloseFilled v-else></CircleCloseFilled></el-icon>
<div><div class="b-label">服务状态</div><div class="b-value">{{ healthOK ? '运行中' : '异常' }}</div></div>
</div>
<div class="banner-item">
<el-icon><Timer></Timer></el-icon>
<div><div class="b-label">运行时长</div><div class="b-value">{{ fmtUptime(sys.uptime_seconds) }}</div></div>
</div>
<div class="banner-item">
<el-icon><Cpu></Cpu></el-icon>
<div><div class="b-label">生效模型</div>
<div class="b-value">{{ activeModelText }}</div></div>
</div>
<div class="banner-item">
<el-icon><Odometer></Odometer></el-icon>
<div><div class="b-label">enhance 并发</div>
<div class="b-value">{{ sys.enhance_concurrency ? sys.enhance_concurrency.used + ' / ' + sys.enhance_concurrency.capacity : '-' }}</div></div>
</div>
<div class="banner-item">
<el-icon><Umbrella></Umbrella></el-icon>
<div><div class="b-label">医疗守卫</div>
<div class="b-value">{{ agentCfg ? (agentCfg.medical_guard.enabled ? '开启' : '关闭') : '-' }}</div></div>
</div>
</div>
<!-- 核心指标四卡 -->
<div class="stat-grid" style="margin-bottom: 16px">
<div class="glass-card stat-card">
<div class="stat-value">{{ stats.total ?? 0 }}</div>
<div class="stat-label">总运行数(最近 {{ bufferSize }} 条内)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value ok">{{ successRate }}</div>
<div class="stat-label">成功率(失败 {{ stats.failed ?? 0 }} / 拦截 {{ stats.blocked ?? 0 }}</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value warn">{{ fmtMs(stats.avg_ms ?? 0) }}</div>
<div class="stat-label">平均耗时(成功请求)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value">{{ stats.total_tokens ?? 0 }}</div>
<div class="stat-label">Token 消耗合计</div>
</div>
</div>
<!-- 最近运行 -->
<div class="glass-card" style="margin-bottom: 16px">
<div class="toolbar" style="margin-bottom: 8px">
<span class="section-title" style="margin: 0">最近运行</span>
<el-button size="small" text type="primary" style="margin-left: auto"
@click="switchPage('runs')">查看全部
<el-icon style="margin-left: 4px"><ArrowRight></ArrowRight></el-icon>
</el-button>
</div>
<el-table :data="runs.slice(0, 5)" style="width: 100%" @row-click="openRunDetail"
:row-style="{ cursor: 'pointer' }" empty-text="暂无运行记录">
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmtTime(row.started_at) }}</template>
</el-table-column>
<el-table-column prop="scene" label="场景" width="140"></el-table-column>
<el-table-column label="模型" min-width="140">
<template #default="{ row }">
<span v-if="row.provider">{{ row.provider }} / {{ row.model }}</span>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">{{ fmtMs(row.total_ms) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 3" type="warning" size="small">拦截</el-tag>
<el-tag v-else type="danger" size="small">失败</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 快捷入口 -->
<div class="glass-card">
<div class="section-title">快捷操作</div>
<div class="toolbar" style="margin: 0">
<el-button size="small" type="primary" plain @click="switchPage('debug')">
<el-icon style="margin-right: 4px"><Connection></Connection></el-icon>模型连通性测试
</el-button>
<el-button size="small" type="success" plain @click="switchPage('debug')">
<el-icon style="margin-right: 4px"><Umbrella></Umbrella></el-icon>守卫测试台
</el-button>
<el-button size="small" type="warning" plain @click="invalidateCache">
<el-icon style="margin-right: 4px"><Refresh></Refresh></el-icon>失效配置缓存
</el-button>
<el-button size="small" plain @click="switchPage('kb')">
<el-icon style="margin-right: 4px"><Collection></Collection></el-icon>知识库管理
</el-button>
</div>
</div>
</div>
<!-- ================= 运行记录 ================= -->
<div v-show="activePage === 'runs'">
<div class="glass-card">
<div class="toolbar">
<el-select v-model="filterScene" placeholder="全部场景" size="small" clearable
style="width: 160px" @change="loadRuns">
<el-option v-for="s in sceneOptions" :key="s" :label="s" :value="s"></el-option>
</el-select>
<el-select v-model="filterStatus" placeholder="全部状态" size="small" clearable
style="width: 130px" @change="loadRuns">
<el-option label="成功" :value="1"></el-option>
<el-option label="失败" :value="2"></el-option>
<el-option label="守卫拦截" :value="3"></el-option>
</el-select>
<el-button size="small" type="primary" plain @click="loadRuns" :loading="loadingRuns">刷新</el-button>
<span class="muted">内存环形缓冲,最多保留最近 {{ bufferSize }} 条;服务重启后清空(历史明细在 PHP xk_ai_generation_step 表)</span>
</div>
<el-table :data="runs" style="width: 100%" @row-click="openRunDetail"
:row-style="{ cursor: 'pointer' }" empty-text="暂无运行记录,跑一次开方/病历生成后刷新">
<el-table-column prop="id" label="#" width="60"></el-table-column>
<el-table-column label="时间" width="160">
<template #default="{ row }">{{ fmtTime(row.started_at) }}</template>
</el-table-column>
<el-table-column prop="scene" label="场景" width="140"></el-table-column>
<el-table-column label="模型" min-width="150">
<template #default="{ row }">
<span v-if="row.provider">{{ row.provider }} / {{ row.model }}</span>
<span v-else class="muted">-</span>
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">{{ fmtMs(row.total_ms) }}</template>
</el-table-column>
<el-table-column prop="total_tokens" label="Token" width="90"></el-table-column>
<el-table-column label="步骤" width="120">
<template #default="{ row }">
<span class="step-dots">
<el-tooltip v-for="(b, i) in row.step_briefs" :key="i"
:content="b.step_type + ' · ' + fmtMs(b.duration_ms)" placement="top">
<span class="step-dot" :class="'s' + b.status"></span>
</el-tooltip>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 3" type="warning" size="small">守卫拦截</el-tag>
<el-tag v-else type="danger" size="small">失败</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- ================= 统计概览 ================= -->
<div v-show="activePage === 'stats'">
<div class="stat-grid" style="margin-bottom: 16px">
<div class="glass-card stat-card">
<div class="stat-value">{{ stats.total ?? 0 }}</div>
<div class="stat-label">总运行数(最近 {{ bufferSize }} 条内)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value ok">{{ successRate }}</div>
<div class="stat-label">成功率(成功 {{ stats.success ?? 0 }} / 失败 {{ stats.failed ?? 0 }} / 拦截 {{ stats.blocked ?? 0 }}</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value warn">{{ fmtMs(stats.avg_ms ?? 0) }}</div>
<div class="stat-label">平均耗时(成功请求)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value">{{ stats.total_tokens ?? 0 }}</div>
<div class="stat-label">Token 消耗合计</div>
</div>
</div>
<div class="glass-card" style="margin-bottom: 16px">
<div class="section-title">耗时趋势(最近 {{ runs.length }} 次运行,红点为失败/拦截)</div>
<div class="chart-box" ref="durationChartEl"></div>
</div>
<div class="glass-card" style="margin-bottom: 16px">
<div class="section-title">Token 消耗</div>
<div class="chart-box" ref="tokenChartEl"></div>
</div>
<div class="glass-card" style="margin-bottom: 16px">
<div class="section-title">按场景分布</div>
<template v-if="sceneEntries.length">
<div class="cfg-row" v-for="[k, v] in sceneEntries" :key="k">
<div class="cfg-key">{{ k || '(未标注场景)' }}</div>
<div class="cfg-val">{{ v }} 次</div>
</div>
</template>
<div v-else class="muted">暂无数据</div>
</div>
<div class="glass-card" v-if="stats.last_error">
<div class="section-title" style="color: var(--danger)">最近一次失败</div>
<div class="muted" style="margin-bottom: 6px">{{ fmtTime(stats.last_error_at) }}</div>
<div class="step-detail">{{ stats.last_error }}</div>
</div>
</div>
<!-- ================= 实时日志 ================= -->
<div v-show="activePage === 'logs'">
<div class="glass-card">
<div class="toolbar">
<el-input v-model="logFilter" placeholder="关键字过滤(前端过滤已拉取的行)" size="small"
clearable style="width: 280px"></el-input>
<el-switch v-model="logAutoScroll" size="small" active-text="自动滚动"></el-switch>
<el-button size="small" plain @click="clearLogView">清空视图</el-button>
<span class="muted">内存缓冲最近 500 行 · 2s 增量轮询 · debug 开关打开时日志含请求体(注意隐私)</span>
</div>
<div class="log-viewer" ref="logViewerEl">
<div v-for="l in filteredLogs" :key="l.id" class="log-line" v-html="colorizeLog(l.line)"></div>
<div v-if="!filteredLogs.length" class="muted">暂无日志(或全部被过滤)</div>
</div>
</div>
</div>
<!-- ================= 系统状态 ================= -->
<div v-show="activePage === 'system'">
<div class="stat-grid" style="margin-bottom: 16px">
<div class="glass-card stat-card">
<div class="stat-value">{{ fmtUptime(sys.uptime_seconds) }}</div>
<div class="stat-label">运行时长({{ fmtTime(sys.started_at) }} 启动)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value" :class="{ bad: sys.enhance_concurrency && sys.enhance_concurrency.used >= sys.enhance_concurrency.capacity }">
{{ sys.enhance_concurrency ? sys.enhance_concurrency.used + ' / ' + sys.enhance_concurrency.capacity : '-' }}
</div>
<div class="stat-label">enhance 当前并发 / 上限(满载时新请求 503</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value warn">{{ sys.heap_alloc_mb != null ? sys.heap_alloc_mb.toFixed(1) + ' MB' : '-' }}</div>
<div class="stat-label">堆内存占用OS 共 {{ sys.sys_mb != null ? sys.sys_mb.toFixed(0) : '-' }} MB · GC {{ sys.num_gc ?? '-' }} 次)</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value">{{ sys.goroutines ?? '-' }}</div>
<div class="stat-label">Goroutine 数({{ sys.go_version || '' }}</div>
</div>
<div class="glass-card stat-card">
<div class="stat-value">{{ sys.runlog_usage ? sys.runlog_usage.used + ' / ' + sys.runlog_usage.capacity : '-' }}</div>
<div class="stat-label">RunLog 缓冲占用</div>
</div>
</div>
<div class="glass-card">
<div class="section-title">服务健康(/health</div>
<template v-if="healthEntries.length">
<div class="cfg-row" v-for="[k, v] in healthEntries" :key="k">
<div class="cfg-key">{{ k }}</div>
<div class="cfg-val">{{ v }}</div>
</div>
</template>
<div v-else class="muted">加载中…</div>
</div>
</div>
<!-- ================= 调试工具 ================= -->
<div v-show="activePage === 'debug'">
<el-collapse v-model="debugPanels" style="border: none">
<el-collapse-item name="guard">
<template #title><span class="section-title" style="margin: 0">医疗守卫测试台</span></template>
<div class="glass-card">
<el-input v-model="guardText" type="textarea" :rows="4"
placeholder="贴一段 prompt / 用户输入,测试守卫会不会拦截、命中哪些关键词"></el-input>
<div style="margin-top: 12px">
<el-button type="primary" size="small" @click="runGuardTest" :loading="guardLoading">测试</el-button>
</div>
<template v-if="guardResult">
<div style="margin-top: 14px">
<el-tag :type="guardResult.passed ? 'success' : 'danger'" size="large">
{{ guardResult.passed ? '放行' : '拦截' }}
</el-tag>
<span v-if="guardResult.reason" class="muted" style="margin-left: 10px">{{ guardResult.reason }}</span>
</div>
<div class="kw-tags" v-if="(guardResult.hit_whitelist || []).length">
<span class="muted" style="align-self: center">命中白名单:</span>
<el-tag v-for="w in guardResult.hit_whitelist" :key="'w'+w" type="success" size="small">{{ w }}</el-tag>
</div>
<div class="kw-tags" v-if="(guardResult.hit_blacklist || []).length">
<span class="muted" style="align-self: center">命中黑名单:</span>
<el-tag v-for="b in guardResult.hit_blacklist" :key="'b'+b" type="danger" size="small">{{ b }}</el-tag>
</div>
</template>
</div>
</el-collapse-item>
<el-collapse-item name="model">
<template #title><span class="section-title" style="margin: 0">模型连通性测试</span></template>
<div class="glass-card">
<div class="toolbar">
<el-input v-model="modelTestProvider" placeholder="provider留空 = 当前生效配置)"
size="small" clearable style="width: 240px"></el-input>
<el-input v-model="modelTestMessage" placeholder="测试消息(留空用默认)"
size="small" clearable style="width: 300px"></el-input>
<el-button type="primary" size="small" @click="runModelTest" :loading="modelTestLoading">发送测试</el-button>
<span class="muted">会真实调用一次 LLMmax_tokens 64成本可忽略</span>
</div>
<template v-if="modelTestResult">
<div class="cfg-row"><div class="cfg-key">结果</div>
<div class="cfg-val">
<el-tag :type="modelTestResult.ok ? 'success' : 'danger'" size="small">
{{ modelTestResult.ok ? '连通' : '失败' }}
</el-tag>
<span style="margin-left: 8px">{{ fmtMs(modelTestResult.duration_ms) }}</span>
</div>
</div>
<div class="cfg-row"><div class="cfg-key">模型</div>
<div class="cfg-val">{{ modelTestResult.provider }} / {{ modelTestResult.model }}(来源: {{ modelTestResult.cfg_source }} · key#{{ modelTestResult.api_key_id }}</div></div>
<div class="cfg-row" v-if="modelTestResult.reply"><div class="cfg-key">回复</div>
<div class="cfg-val">{{ modelTestResult.reply }}</div></div>
<div class="cfg-row" v-if="modelTestResult.error"><div class="cfg-key">错误</div>
<div class="cfg-val" style="color: var(--danger)">{{ modelTestResult.error }}</div></div>
</template>
</div>
</el-collapse-item>
<el-collapse-item name="kb">
<template #title><span class="section-title" style="margin: 0">知识库检索测试</span></template>
<div class="glass-card">
<div class="toolbar">
<el-input v-model="kbTestQuery" placeholder="检索关键词,如「痰湿中阻 煎法」"
size="small" clearable style="width: 300px" @keyup.enter="runKBTest"></el-input>
<el-input-number v-model="kbTestTopK" :min="1" :max="20" size="small"></el-input-number>
<el-button type="primary" size="small" @click="runKBTest" :loading="kbTestLoading">检索</el-button>
<span class="muted">走 enhance 同款检索路径(含 ai_kb_source 分流),看 Agent 实际检索到什么</span>
</div>
<template v-if="kbTestResult">
<div class="muted" style="margin-bottom: 10px">
{{ kbTestResult.source }} · 耗时 {{ fmtMs(kbTestResult.duration_ms) }}
<span v-if="kbTestResult.error" style="color: var(--danger)"> · {{ kbTestResult.error }}</span>
</div>
<div v-for="(doc, i) in kbTestResult.docs || []" :key="i" class="kb-doc">{{ doc }}</div>
<div v-if="!kbTestResult.error && !(kbTestResult.docs || []).length" class="muted">未命中任何文档</div>
</template>
</div>
</el-collapse-item>
<el-collapse-item name="enhance">
<template #title><span class="section-title" style="margin: 0">Enhance 测试台(真实完整链路)</span></template>
<div class="glass-card">
<div class="toolbar">
<el-select v-model="enhScene" size="small" style="width: 180px">
<el-option label="medical_record" value="medical_record"></el-option>
<el-option label="prescription" value="prescription"></el-option>
</el-select>
<el-switch v-model="enhKBEnabled" size="small" active-text="启用知识库"></el-switch>
<el-button type="primary" size="small" @click="runEnhanceTest" :loading="enhLoading">发起请求</el-button>
<span class="muted">走真实 /agent/enhance守卫 → 检索 → ReAct 完整链路(消耗真实 token</span>
</div>
<el-input v-model="enhSystem" type="textarea" :rows="2" placeholder="system 消息"
style="margin-bottom: 8px"></el-input>
<el-input v-model="enhUser" type="textarea" :rows="4" placeholder="user 消息"></el-input>
<template v-if="enhResult">
<div style="margin: 14px 0 8px">
<el-tag :type="enhResult.ok ? 'success' : 'danger'" size="small">{{ enhResult.ok ? '成功' : '失败' }}</el-tag>
<span class="muted" style="margin-left: 8px">{{ enhResult.provider }} / {{ enhResult.model }} · {{ fmtMs(enhResult.total_ms) }}</span>
<el-button size="small" text type="primary" style="margin-left: 8px"
@click="showEnhanceTimeline">查看步骤时间线</el-button>
</div>
<div class="step-detail">{{ enhResult.content || enhResult.message || '(无输出)' }}</div>
</template>
</div>
</el-collapse-item>
</el-collapse>
</div>
<!-- ================= 配置总览 ================= -->
<div v-show="activePage === 'config'">
<div class="glass-card" style="margin-bottom: 16px">
<div class="toolbar">
<el-button size="small" type="primary" plain @click="loadConfig" :loading="loadingCfg">刷新</el-button>
<el-button size="small" type="warning" plain @click="invalidateCache">失效配置缓存</el-button>
<span class="muted">面板只读:开关修改统一在 PHP 后台(避免双写入口);「失效缓存」让 Go 立即重读 DB 配置</span>
</div>
<div class="section-title">生效模型(/models/active-configDB 实时解析)</div>
<template v-if="cfgEntries.length">
<div class="cfg-row" v-for="[k, v] in cfgEntries" :key="k">
<div class="cfg-key">{{ k }}</div>
<div class="cfg-val">{{ v }}</div>
</div>
</template>
<div v-else class="muted">{{ cfgError || '加载中…' }}</div>
</div>
<div class="glass-card" style="margin-bottom: 16px">
<div class="section-title">Agent 行为配置xk_system_config 只读视图)</div>
<template v-if="agentCfg">
<div class="cfg-row"><div class="cfg-key">ReAct 循环</div>
<div class="cfg-val">{{ agentCfg.react.enabled ? '开启' : '关闭' }} · 最多 {{ agentCfg.react.max_iterations }} 轮 · Planning {{ agentCfg.react.planning_enabled ? '开' : '关' }} · Reflection {{ agentCfg.react.reflection_enabled ? '开' : '关' }} · JSON修复 {{ agentCfg.react.json_repair_enabled ? '开' : '关' }}</div></div>
<div class="cfg-row"><div class="cfg-key">Token 预算</div>
<div class="cfg-val">{{ agentCfg.token_budget.enabled ? '开启' : '关闭' }} · 单任务上限 {{ agentCfg.token_budget.per_request }} · 单次调用上限 {{ agentCfg.token_budget.max_tokens_per_call }}</div></div>
<div class="cfg-row"><div class="cfg-key">知识库</div>
<div class="cfg-val">source={{ agentCfg.kb.source }} · embedding={{ agentCfg.kb.embedding_provider }} · top_k={{ agentCfg.kb.top_k }} · mode={{ agentCfg.kb.search_mode }}</div></div>
<div class="cfg-row"><div class="cfg-key">医疗守卫</div>
<div class="cfg-val">{{ agentCfg.medical_guard.enabled ? '开启' : '关闭' }}(白名单 {{ agentCfg.medical_guard.whitelist.length }} 词 / 黑名单 {{ agentCfg.medical_guard.blacklist.length }} 词)</div></div>
<div class="cfg-row"><div class="cfg-key">调试日志</div>
<div class="cfg-val">{{ agentCfg.debug.log_request_body ? '开启(日志含请求体,注意隐私)' : '关闭' }}</div></div>
</template>
<div v-else class="muted">加载中…</div>
</div>
<div class="glass-card" style="margin-bottom: 16px" v-if="agentCfg">
<div class="section-title">守卫关键词表</div>
<div class="muted" style="margin-bottom: 6px">白名单(命中任一放行):</div>
<div class="kw-tags" style="margin-bottom: 12px">
<el-tag v-for="w in agentCfg.medical_guard.whitelist" :key="'cw'+w" type="success" size="small">{{ w }}</el-tag>
</div>
<div class="muted" style="margin-bottom: 6px">黑名单(白名单未命中且命中任一则拦截):</div>
<div class="kw-tags">
<el-tag v-for="b in agentCfg.medical_guard.blacklist" :key="'cb'+b" type="danger" size="small">{{ b }}</el-tag>
</div>
</div>
<div class="glass-card">
<div class="section-title">场景路由表(/models/routes</div>
<template v-if="routeEntries.length">
<div class="cfg-row" v-for="[k, v] in routeEntries" :key="k">
<div class="cfg-key">{{ k }}</div>
<div class="cfg-val">{{ v }}</div>
</div>
</template>
<div v-else class="muted">加载中…</div>
</div>
</div>
<!-- ================= 知识库管理(自 /kb/view 并入) ================= -->
<div v-show="activePage === 'kb'">
<!-- 库列表视图 -->
<div v-if="!currentLib">
<div class="toolbar">
<span class="muted">共 {{ libraries.length }} 个知识库 · V1 全文检索MySQL ngram</span>
<el-button size="small" type="primary" style="margin-left: auto" @click="openCreateLib">
<el-icon style="margin-right: 4px"><Plus></Plus></el-icon>新建知识库
</el-button>
</div>
<div v-if="kbLoading.libs" class="lib-grid">
<div v-for="i in 3" :key="i" class="skeleton-card"></div>
</div>
<div v-else-if="libraries.length === 0" class="glass-card empty-state">
<div class="icon-wrap"><el-icon size="32"><FolderAdd></FolderAdd></el-icon></div>
<h3>还没有任何知识库</h3>
<p>点击右上角「新建知识库」创建第一个库</p>
</div>
<div v-else class="lib-grid">
<div v-for="lib in libraries" :key="lib.id" class="lib-card" @click="openLibrary(lib)">
<div class="lib-card-header">
<div class="lib-icon-wrap"><el-icon size="20"><Folder></Folder></el-icon></div>
<el-dropdown trigger="click" @command="cmd => onLibCommand(cmd, lib)">
<el-button text circle @click.stop><el-icon><MoreFilled></MoreFilled></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="search"><el-icon><Search></Search></el-icon> 在此库检索</el-dropdown-item>
<el-dropdown-item command="delete" divided><el-icon><Delete></Delete></el-icon> 删除库</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="lib-title">{{ lib.name }}</div>
<div class="lib-desc">{{ lib.description || '暂无描述' }}</div>
<div class="lib-stats">
<div class="lib-stat"><div class="num">{{ lib.doc_count }}</div><div class="lbl">文档</div></div>
<div class="lib-stat"><div class="num">{{ lib.chunk_count }}</div><div class="lbl">分段</div></div>
<div class="lib-stat"><div class="num">{{ lib.vectorized_count }}</div><div class="lbl">已向量</div></div>
</div>
<div class="lib-card-footer">
<span class="source-pill">{{ lib.source === 'maxkb_export' ? 'MaxKB 导出' : '手动建立' }}</span>
<span class="muted">{{ fmtDate(lib.created_at) }}</span>
</div>
</div>
</div>
</div>
<!-- 库详情视图(文档列表 + 上传) -->
<div v-else>
<div class="toolbar">
<el-button size="small" @click="exitLibrary">
<el-icon style="margin-right: 4px"><ArrowLeft></ArrowLeft></el-icon>返回列表
</el-button>
<span class="section-title" style="margin: 0">{{ currentLib.name }}</span>
<span class="muted">{{ currentLib.doc_count }} 篇文档 · {{ currentLib.chunk_count }} 个分段</span>
<el-button size="small" type="primary" plain style="margin-left: auto"
@click="jumpToKBSearch(currentLib.id)">
<el-icon style="margin-right: 4px"><Search></Search></el-icon>检索测试
</el-button>
</div>
<div class="drop-zone" :class="{ dragover: isDragOver }" @click="triggerFileInput"
@dragover.prevent="isDragOver = true" @dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop">
<input type="file" ref="fileInput" style="display:none" accept=".xlsx,.xls,.csv,.md,.txt,.pdf,.docx,.html,.htm"
@change="handleFileInput" />
<div v-if="kbLoading.import">
<el-icon class="is-loading" size="28" color="var(--accent)"><Loading></Loading></el-icon>
<div class="drop-title" style="color: var(--accent)">正在解析与入库…</div>
</div>
<template v-else>
<el-icon size="28" color="var(--accent)"><UploadFilled></UploadFilled></el-icon>
<div class="drop-title">点击上传或拖拽文件到此处</div>
<div class="drop-tip">支持 .xlsx / .csv / .md / .txt / .pdf / .docx / .html · 自动分段 · 单文件最大 50MB</div>
<div class="drop-tip">PDF 需为文字版(扫描版请先 OCR· 老版 .doc/.xls 请先另存为 .docx/.xlsx</div>
</template>
</div>
<div class="doc-list" v-loading="kbLoading.docs">
<div v-for="doc in docs" :key="doc.id" class="doc-item">
<div class="doc-info">
<div class="doc-title">{{ doc.title }}</div>
<div class="doc-meta">
<span>{{ doc.source_file }}</span>
<el-tag size="small" effect="plain">{{ doc.source_type }}</el-tag>
<span>{{ doc.chunk_count }} 个分段</span>
<span>{{ fmtDate(doc.created_at) }}</span>
</div>
</div>
<div style="display:flex; gap:6px;">
<el-button size="small" @click="openChunksDrawer(doc)">
<el-icon style="margin-right: 4px"><View></View></el-icon>查看分段
</el-button>
<el-button size="small" type="danger" plain circle @click="deleteDoc(doc)">
<el-icon><Delete></Delete></el-icon>
</el-button>
</div>
</div>
<div v-if="!kbLoading.docs && docs.length === 0" class="glass-card empty-state">
<div class="icon-wrap"><el-icon size="32"><Document></Document></el-icon></div>
<h3>该库还没有任何文档</h3>
<p>把文件拖到上面的上传区即可导入</p>
</div>
</div>
</div>
</div>
<!-- ================= 知识库检索(自 /kb/view 并入) ================= -->
<div v-show="activePage === 'kbsearch'">
<div class="glass-card" style="margin-bottom: 16px">
<el-form label-position="top">
<el-form-item label="选择知识库">
<el-select v-model="searchForm.library_id" placeholder="请选择知识库" style="width:100%;">
<el-option v-for="lib in libraries" :key="lib.id"
:label="lib.name + '' + lib.chunk_count + ' 个分段)'"
:value="lib.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="检索词">
<el-input v-model="searchForm.query" placeholder="例如:痰湿中阻 怎么治" size="large"
clearable @keyup.enter="runSearch">
<template #append>
<el-button :loading="kbLoading.search" @click="runSearch">
<el-icon style="margin-right: 4px"><Search></Search></el-icon>检索
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="高级">
<div class="toolbar" style="margin: 0">
<span class="muted">返回条数:</span>
<el-input-number v-model="searchForm.top_k" :min="1" :max="20" size="small"></el-input-number>
<span class="muted">检索模式:</span>
<el-radio-group v-model="searchForm.mode" size="small">
<el-radio-button label="fulltext">全文</el-radio-button>
<el-radio-button label="vector" disabled>向量</el-radio-button>
<el-radio-button label="blend" disabled>混合</el-radio-button>
</el-radio-group>
</div>
</el-form-item>
</el-form>
</div>
<div v-if="searchResults.length > 0">
<div class="toolbar" style="margin-bottom: 10px">
<span class="section-title" style="margin: 0">检索结果({{ searchResults.length }} 条)</span>
<span class="muted">耗时 {{ searchDurationMs }}ms · 排序按 BM25 相关度</span>
</div>
<div v-for="(r, idx) in searchResults" :key="r.chunk_id" class="result-card">
<div class="result-title">
<el-tag size="small" type="info" effect="plain">#{{ idx + 1 }}</el-tag>
{{ r.title || '(无标题)' }}
<!-- 精确匹配boolean不打标自然语言回退命中时提示便于判断召回质量 -->
<el-tag v-if="r.source_type === 'fulltext-natural'" size="small" type="warning" effect="plain">分词回退</el-tag>
</div>
<div class="result-content">{{ r.content }}</div>
<div class="score-bar">
<span class="muted">相关度</span>
<el-progress :percentage="scorePercent(r.score)" :stroke-width="5" :show-text="false"
style="flex:1; max-width:180px;" color="var(--accent)"></el-progress>
<el-tag size="small" type="success">{{ r.score.toFixed(3) }}</el-tag>
</div>
</div>
</div>
<div v-else-if="searchedOnce" class="glass-card empty-state">
<div class="icon-wrap"><el-icon size="32"><Search></Search></el-icon></div>
<h3>没有找到相关内容</h3>
<p>换个关键词试试。短词走精确子串匹配;长句/整段会自动分词做相关度检索,仍无结果说明库内确实没有相关内容</p>
</div>
</div>
</div>
</div>
</div>
<!-- 运行详情抽屉el-timeline 步骤时间线 -->
<el-drawer v-model="drawerVisible" :title="'运行 #' + (detail?.id ?? '') + ' · ' + (detail?.scene ?? '')"
size="620px" destroy-on-close>
<template v-if="detail">
<div class="glass-card" style="margin-bottom: 16px">
<div class="cfg-row"><div class="cfg-key">状态</div>
<div class="cfg-val">
<el-tag v-if="detail.status === 1" type="success" size="small">成功</el-tag>
<el-tag v-else-if="detail.status === 3" type="warning" size="small">守卫拦截</el-tag>
<el-tag v-else type="danger" size="small">失败</el-tag>
</div>
</div>
<div class="cfg-row"><div class="cfg-key">模型</div>
<div class="cfg-val">{{ detail.provider || '-' }} / {{ detail.model || '-' }}(来源: {{ detail.cfg_source || '-' }}</div></div>
<div class="cfg-row"><div class="cfg-key">时间</div>
<div class="cfg-val">{{ fmtTime(detail.started_at) }} → {{ fmtTime(detail.finished_at) }}{{ fmtMs(detail.total_ms) }}</div></div>
<div class="cfg-row"><div class="cfg-key">Token</div>
<div class="cfg-val">输入 {{ detail.prompt_tokens }} + 输出 {{ detail.completion_tokens }} = {{ detail.total_tokens }}</div></div>
<div class="cfg-row" v-if="detail.error"><div class="cfg-key">错误</div>
<div class="cfg-val" style="color: var(--danger)">{{ detail.error }}</div></div>
</div>
<el-timeline v-if="detail.steps && detail.steps.length">
<el-timeline-item v-for="(st, i) in detail.steps" :key="i"
:type="st.status === 2 ? 'danger' : (st.status === 1 ? 'success' : 'info')"
:timestamp="fmtTime(st.started_at) + ' · ' + fmtMs(st.duration_ms)" placement="top">
<div class="timeline-card-title">
{{ stepTypeName(st.step_type) }}
<el-tag size="small" :type="st.status === 2 ? 'danger' : 'success'">
{{ st.status === 2 ? '失败' : '完成' }}
</el-tag>
</div>
<div class="step-meta" v-if="st.provider">
{{ st.provider }} / {{ st.model }}
<template v-if="st.total_tokens"> · token {{ st.prompt_tokens }}+{{ st.completion_tokens }}={{ st.total_tokens }}</template>
<template v-if="st.api_key_id"> · key#{{ st.api_key_id }}</template>
</div>
<div class="step-detail" v-if="st.detail">{{ st.detail }}</div>
</el-timeline-item>
</el-timeline>
<div v-else class="muted">本次运行无步骤明细</div>
</template>
</el-drawer>
<!-- 新建知识库对话框 -->
<el-dialog v-model="kbDialog.createLib" title="新建知识库" width="460px">
<el-form label-position="top">
<el-form-item label="库名" required>
<el-input v-model="createForm.name" placeholder="例如:中医诊疗资料 v1"></el-input>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选"></el-input>
</el-form-item>
<el-form-item label="来源">
<el-radio-group v-model="createForm.source">
<el-radio label="manual">手动建立</el-radio>
<el-radio label="maxkb_export">从 MaxKB 导出</el-radio>
</el-radio-group>
</el-form-item>
<el-alert type="info" :closable="false">
V1 默认不做向量化,检索走 MySQL FULLTEXTngram 2-gram 中文分词)。
</el-alert>
</el-form>
<template #footer>
<el-button @click="kbDialog.createLib = false">取消</el-button>
<el-button type="primary" :loading="kbLoading.create" @click="submitCreateLib">创建</el-button>
</template>
</el-dialog>
<!-- 分段查看抽屉 -->
<el-drawer v-model="chunksDrawer.visible" :title="chunksDrawer.title" direction="rtl" size="55%" destroy-on-close>
<div v-loading="kbLoading.chunks" style="padding: 0 4px">
<div v-for="chunk in chunks" :key="chunk.id" class="chunk-item">
<div class="chunk-head">
<span class="chunk-idx">{{ chunk.chunk_index + 1 }}</span>
<span class="chunk-title">{{ chunk.title || '(无标题)' }}</span>
<span style="flex:1;"></span>
<el-button size="small" plain @click="openEditChunk(chunk)">
<el-icon style="margin-right: 4px"><Edit></Edit></el-icon>编辑
</el-button>
</div>
<div class="chunk-content">{{ chunk.content }}</div>
<div v-if="getRelatedQuestions(chunk.meta_json)" class="chunk-related">
<strong>关联问题:</strong>{{ getRelatedQuestions(chunk.meta_json) }}
</div>
</div>
<div v-if="!kbLoading.chunks && chunks.length === 0" class="empty-state" style="padding: 40px">
<div class="icon-wrap" style="width: 56px; height: 56px">
<el-icon size="24"><Document></Document></el-icon>
</div>
<h3 style="font-size: 14px">无分段</h3>
</div>
</div>
</el-drawer>
<!-- 分段编辑对话框 -->
<el-dialog v-model="editChunkDialog.visible" title="编辑分段" width="640px" :close-on-click-modal="false">
<el-form label-position="top">
<el-form-item label="标题">
<el-input v-model="editChunkForm.title" placeholder="可空(如来自 md 的标题)"></el-input>
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="editChunkForm.content" type="textarea" :rows="10" placeholder="分段正文(必填)"></el-input>
</el-form-item>
<el-form-item label="关联问题">
<div style="width: 100%">
<div v-for="(q, idx) in editChunkForm.relatedQuestions" :key="idx"
style="display:flex; gap:8px; margin-bottom:8px;">
<el-input v-model="editChunkForm.relatedQuestions[idx]" placeholder="如:痰湿中阻怎么治"></el-input>
<el-button type="danger" plain circle @click="editChunkForm.relatedQuestions.splice(idx, 1)">
<el-icon><Delete></Delete></el-icon>
</el-button>
</div>
<el-button size="small" plain @click="editChunkForm.relatedQuestions.push('')">
<el-icon style="margin-right: 4px"><Plus></Plus></el-icon>添加关联问题
</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editChunkDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="editChunkDialog.saving" @click="saveEditChunk">保存</el-button>
</template>
</el-dialog>
</div>
<script>
/* ============================================================
* TCM Agent 控制台(左侧菜单 + 首页仪表盘)
* ============================================================
* 页面:首页 / 运行记录 / 统计概览 / 实时日志 / 系统状态 / 调试工具 / 配置总览
*
* 鉴权:与 KB 页共用管理口令X-KB-Admin-Password 头),
* localStorage 共用 kb_admin_pwd——KB 页输过口令这里免输,反之亦然。
* 401 时自动清缓存重新弹口令框。
*
* localStorage 记忆:
* - agent_view_tab 当前菜单页
* - agent_view_refresh 自动刷新开关
* - agent_view_menu_collapse 菜单折叠状态
* - kb_admin_pwd 管理口令(与 KB 页共用)
* - kb_theme 主题(与 KB 页共用,联动切换)
* ============================================================ */
const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue;
const TAB_KEY = 'agent_view_tab';
const REFRESH_KEY = 'agent_view_refresh';
const COLLAPSE_KEY = 'agent_view_menu_collapse';
const PWD_STORAGE_KEY = 'kb_admin_pwd'; // 与 KB 页共用
const THEME_KEY = 'kb_theme'; // 与 KB 页共用
const app = createApp({
setup() {
// ---------- 主题 ----------
const manualTheme = ref(localStorage.getItem(THEME_KEY) || '');
const isDark = computed(() => {
if (manualTheme.value) return manualTheme.value === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
function applyTheme() {
const t = manualTheme.value || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', t);
document.documentElement.classList.toggle('dark', t === 'dark');
}
function toggleTheme() {
manualTheme.value = isDark.value ? 'light' : 'dark';
localStorage.setItem(THEME_KEY, manualTheme.value);
applyTheme();
rebuildCharts();
}
// ---------- 口令管理(与 KB 页同款交互) ----------
const pwdDialog = reactive({ visible: false, loading: false, _resolve: null });
const pwdInput = ref('');
const cachedPwd = ref(localStorage.getItem(PWD_STORAGE_KEY) || '');
// requirePwd没有缓存口令时弹框等用户输入Promise 在验证成功后 resolve
function requirePwd() {
return new Promise((resolve) => {
if (cachedPwd.value) { resolve(cachedPwd.value); return; }
pwdInput.value = '';
pwdDialog.visible = true;
pwdDialog._resolve = resolve;
});
}
// submitPwd用最轻量的 /api/v1/agent/stats 验证口令是否正确
async function submitPwd() {
if (!pwdInput.value.trim()) { ElementPlus.ElMessage.warning('请输入口令'); return; }
const pwd = pwdInput.value.trim();
pwdDialog.loading = true;
try {
const res = await fetch('/api/v1/agent/stats', {
headers: { 'X-KB-Admin-Password': pwd },
});
if (res.status === 401) { ElementPlus.ElMessage.error('口令错误'); return; }
cachedPwd.value = pwd;
localStorage.setItem(PWD_STORAGE_KEY, pwd);
pwdDialog.visible = false;
if (pwdDialog._resolve) pwdDialog._resolve(pwd);
} catch (e) {
ElementPlus.ElMessage.error('验证失败: ' + e.message);
} finally {
pwdDialog.loading = false;
}
}
function logout() {
cachedPwd.value = '';
localStorage.removeItem(PWD_STORAGE_KEY);
location.reload();
}
// ---------- 统一请求封装带口令头401 自动重新要口令) ----------
async function apiFetch(url, options = {}) {
let pwd = cachedPwd.value || await requirePwd();
const opts = Object.assign({}, options);
opts.headers = Object.assign({}, options.headers || {}, { 'X-KB-Admin-Password': pwd });
let res = await fetch(url, opts);
if (res.status === 401) {
// 口令过期/被改:清缓存重新弹框再试一次
cachedPwd.value = '';
localStorage.removeItem(PWD_STORAGE_KEY);
pwd = await requirePwd();
opts.headers['X-KB-Admin-Password'] = pwd;
res = await fetch(url, opts);
}
return res.json();
}
function apiGet(url) { return apiFetch(url); }
function apiPost(url, body) {
return apiFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
}
// ---------- 菜单 ----------
// icon 名对应全局注册的 ElementPlusIconsVue 组件(挂载前循环注册,见文件末尾)
const menus = [
{ key: 'home', label: '首页', icon: 'HomeFilled' },
{ key: 'runs', label: '运行记录', icon: 'List' },
{ key: 'stats', label: '统计概览', icon: 'TrendCharts' },
{ key: 'logs', label: '实时日志', icon: 'Document' },
{ key: 'system', label: '系统状态', icon: 'Monitor' },
{ key: 'debug', label: '调试工具', icon: 'Tools' },
{ key: 'config', label: '配置总览', icon: 'Setting' },
{ divider: true },
{ group: '知识库' },
{ key: 'kb', label: '知识库管理', icon: 'Collection' },
{ key: 'kbsearch', label: '知识库检索', icon: 'Search' },
];
const activePage = ref(localStorage.getItem(TAB_KEY) || 'home');
// 老用户 localStorage 里可能存着不存在的旧 tab 名,兜底回首页
if (!menus.some(m => m.key === activePage.value)) activePage.value = 'home';
const currentMenuLabel = computed(() => (menus.find(m => m.key === activePage.value) || {}).label || '');
function switchPage(key) {
activePage.value = key;
localStorage.setItem(TAB_KEY, key);
if (key === 'stats') nextTick(() => rebuildCharts());
// 知识库模块懒加载:首次进入才拉库列表(避免面板启动多打一个请求)
if ((key === 'kb' || key === 'kbsearch') && !kbLoadedOnce) loadLibraries();
}
const menuCollapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1');
function toggleCollapse() {
menuCollapsed.value = !menuCollapsed.value;
localStorage.setItem(COLLAPSE_KEY, menuCollapsed.value ? '1' : '0');
}
// ---------- 偏好 ----------
const autoRefresh = ref(localStorage.getItem(REFRESH_KEY) !== '0'); // 默认开
function saveAutoRefresh(v) { localStorage.setItem(REFRESH_KEY, v ? '1' : '0'); }
// ---------- 运行记录 ----------
const runs = ref([]);
const loadingRuns = ref(false);
const filterScene = ref('');
const filterStatus = ref(null);
const bufferSize = ref(200);
const sceneOptions = computed(() => [...new Set(runs.value.map(r => r.scene).filter(Boolean))]);
async function loadRuns(silent) {
if (silent !== true) loadingRuns.value = true;
try {
const params = new URLSearchParams({ limit: '200' });
if (filterScene.value) params.set('scene', filterScene.value);
if (filterStatus.value) params.set('status', String(filterStatus.value));
const j = await apiGet('/api/v1/agent/runs?' + params.toString());
runs.value = j.data || [];
if (activePage.value === 'stats') nextTick(() => rebuildCharts());
} catch (e) {
if (silent !== true) ElementPlus.ElMessage.error('加载运行记录失败:' + e.message);
} finally {
loadingRuns.value = false;
}
}
const drawerVisible = ref(false);
const detail = ref(null);
async function openRunDetail(row) {
try {
const j = await apiGet('/api/v1/agent/runs/' + row.id);
// 环形缓冲只保留最近 N 条:老记录被覆盖后接口返回业务码 404
// 必须检查 code否则会打开一个空白抽屉
if (j.code !== 200 || !j.data) {
ElementPlus.ElMessage.warning(j.message || '记录已被环形缓冲覆盖,无法查看详情');
return;
}
detail.value = j.data;
drawerVisible.value = true;
} catch (e) {
ElementPlus.ElMessage.error('加载详情失败:' + e.message);
}
}
// ---------- 统计 + 图表 ----------
const stats = ref({});
const successRate = computed(() => {
const t = stats.value.total || 0;
if (!t) return '-';
return Math.round((stats.value.success || 0) / t * 100) + '%';
});
const sceneEntries = computed(() => Object.entries(stats.value.scene_counts || {}));
async function loadStats(silent) {
try {
const j = await apiGet('/api/v1/agent/stats');
stats.value = j.data || {};
if (stats.value.buffer_size) bufferSize.value = stats.value.buffer_size;
} catch (e) {
if (silent !== true) ElementPlus.ElMessage.error('加载统计失败:' + e.message);
}
}
const durationChartEl = ref(null);
const tokenChartEl = ref(null);
let durationChart = null;
let tokenChart = null;
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function rebuildCharts() {
const ordered = [...runs.value].reverse();
const labels = ordered.map(r => '#' + r.id);
const textColor = cssVar('--text-tertiary');
const axisCommon = {
axisLabel: { color: textColor },
axisLine: { lineStyle: { color: cssVar('--border') } },
splitLine: { lineStyle: { color: cssVar('--border') } },
};
if (durationChart) { durationChart.dispose(); durationChart = null; }
if (durationChartEl.value) {
durationChart = echarts.init(durationChartEl.value);
durationChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: labels, ...axisCommon },
yAxis: { type: 'value', name: 'ms', ...axisCommon },
series: [{
type: 'line', smooth: true, showSymbol: true,
data: ordered.map(r => ({
value: r.total_ms,
itemStyle: r.status !== 1 ? { color: cssVar('--danger') } : { color: cssVar('--accent') },
})),
lineStyle: { color: cssVar('--accent') },
areaStyle: { opacity: 0.08, color: cssVar('--accent') },
}],
});
}
if (tokenChart) { tokenChart.dispose(); tokenChart = null; }
if (tokenChartEl.value) {
tokenChart = echarts.init(tokenChartEl.value);
tokenChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: labels, ...axisCommon },
yAxis: { type: 'value', name: 'token', ...axisCommon },
series: [
{ name: '总 token', type: 'bar', data: ordered.map(r => r.total_tokens), itemStyle: { color: cssVar('--warning') } },
],
});
}
}
// ---------- 实时日志 ----------
const logs = ref([]);
const logFilter = ref('');
const logAutoScroll = ref(true);
const logViewerEl = ref(null);
let logLastID = 0;
function clearLogView() { logs.value = []; }
const filteredLogs = computed(() => {
if (!logFilter.value) return logs.value;
const kw = logFilter.value.toLowerCase();
return logs.value.filter(l => l.line.toLowerCase().includes(kw));
});
function colorizeLog(line) {
const esc = line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return esc
.replace(/(失败|错误|致命|panic|⛔|❌)/g, '<span class="tag-err">$1</span>')
.replace(/(⚠️|警告|拦截)/g, '<span class="tag-warn">$1</span>')
.replace(/(\[HTTP\])/g, '<span class="tag-http">$1</span>')
.replace(/(\[[A-Za-z\u4e00-\u9fa5]+\])/g, '<span class="tag-mod">$1</span>');
}
async function pollLogs() {
try {
const j = await apiGet('/api/v1/agent/logs?since_id=' + logLastID + '&limit=500');
const d = j.data || {};
const entries = d.entries || [];
if (entries.length) {
logs.value.push(...entries);
if (logs.value.length > 1000) logs.value = logs.value.slice(-1000);
logLastID = d.last_id || logLastID;
if (logAutoScroll.value) {
nextTick(() => {
if (logViewerEl.value) logViewerEl.value.scrollTop = logViewerEl.value.scrollHeight;
});
}
}
} catch (e) { /* 日志轮询静默失败 */ }
}
// ---------- 系统状态 + 健康 ----------
const sys = ref({});
const healthEntries = ref([]);
const healthOK = ref(false);
async function loadSystem() {
try {
const j = await apiGet('/api/v1/agent/system');
sys.value = j.data || {};
} catch (e) { /* 静默 */ }
try {
// /health 无需鉴权,直接 fetch
const res = await fetch('/health');
const h = await res.json();
healthOK.value = h.status === 'ok';
healthEntries.value = Object.entries(h).map(([k, v]) => [k, String(v)]);
} catch (e) { healthOK.value = false; }
}
// ---------- 调试工具 ----------
const debugPanels = ref(['guard']);
const guardText = ref('');
const guardLoading = ref(false);
const guardResult = ref(null);
async function runGuardTest() {
if (!guardText.value.trim()) { ElementPlus.ElMessage.warning('请输入测试文本'); return; }
guardLoading.value = true;
try {
const j = await apiPost('/api/v1/agent/guard-test', { text: guardText.value });
guardResult.value = j.data;
} catch (e) {
ElementPlus.ElMessage.error('测试失败:' + e.message);
} finally { guardLoading.value = false; }
}
const modelTestProvider = ref('');
const modelTestMessage = ref('');
const modelTestLoading = ref(false);
const modelTestResult = ref(null);
async function runModelTest() {
modelTestLoading.value = true;
try {
const j = await apiPost('/api/v1/models/test', {
provider: modelTestProvider.value, message: modelTestMessage.value,
});
modelTestResult.value = j.data;
} catch (e) {
ElementPlus.ElMessage.error('测试失败:' + e.message);
} finally { modelTestLoading.value = false; }
}
const kbTestQuery = ref('');
const kbTestTopK = ref(5);
const kbTestLoading = ref(false);
const kbTestResult = ref(null);
async function runKBTest() {
if (!kbTestQuery.value.trim()) { ElementPlus.ElMessage.warning('请输入检索关键词'); return; }
kbTestLoading.value = true;
try {
const j = await apiPost('/api/v1/agent/kb-test', {
query: kbTestQuery.value, top_k: kbTestTopK.value,
});
kbTestResult.value = j.data;
} catch (e) {
ElementPlus.ElMessage.error('检索失败:' + e.message);
} finally { kbTestLoading.value = false; }
}
const enhScene = ref('medical_record');
const enhKBEnabled = ref(false);
const enhSystem = ref('你是资深中医师,请根据患者信息给出简要辨证分析。');
const enhUser = ref('患者女38岁主诉失眠多梦两周伴心悸健忘舌淡苔薄白脉细弱。');
const enhLoading = ref(false);
const enhResult = ref(null);
async function runEnhanceTest() {
if (!enhUser.value.trim()) { ElementPlus.ElMessage.warning('请输入 user 消息'); return; }
enhLoading.value = true;
enhResult.value = null;
try {
const messages = [];
if (enhSystem.value.trim()) messages.push({ role: 'system', content: enhSystem.value });
messages.push({ role: 'user', content: enhUser.value });
const body = { scene: enhScene.value, messages, kb_enabled: enhKBEnabled.value };
// Go 端检索条件是 kb_enabled && context 非空:开关打开时必须带 context
// 否则永远不会触发知识库检索(用 user 消息作为检索关键词最贴近真实调用)
if (enhKBEnabled.value) {
body.context = enhUser.value;
body.top_k = 5;
}
const j = await apiPost('/api/v1/agent/enhance', body);
const d = j.data || {};
enhResult.value = {
ok: j.code === 200,
content: d.content,
message: j.message,
provider: d.provider || '-',
model: d.model || '-',
total_ms: d.total_ms || 0,
steps: d.steps || [],
};
loadRuns(true);
loadStats(true);
} catch (e) {
ElementPlus.ElMessage.error('请求失败:' + e.message);
} finally { enhLoading.value = false; }
}
function showEnhanceTimeline() {
if (!enhResult.value) return;
let pt = 0, ct = 0, tt = 0;
for (const st of enhResult.value.steps) {
pt += st.prompt_tokens || 0; ct += st.completion_tokens || 0; tt += st.total_tokens || 0;
}
detail.value = {
id: '测试', scene: enhScene.value,
provider: enhResult.value.provider, model: enhResult.value.model,
cfg_source: '-', status: enhResult.value.ok ? 1 : 2,
error: enhResult.value.ok ? '' : (enhResult.value.message || ''),
total_ms: enhResult.value.total_ms,
started_at: 0, finished_at: 0,
prompt_tokens: pt, completion_tokens: ct, total_tokens: tt,
steps: enhResult.value.steps,
};
drawerVisible.value = true;
}
// ---------- 配置总览 ----------
const cfgEntries = ref([]);
const cfgError = ref('');
const loadingCfg = ref(false);
const agentCfg = ref(null);
const routeEntries = ref([]);
// 首页横幅用:生效模型简写
const activeModelText = computed(() => {
const m = Object.fromEntries(cfgEntries.value);
if (!m.provider) return '-';
return m.provider + ' / ' + m.model;
});
async function loadConfig() {
loadingCfg.value = true;
cfgError.value = '';
try {
const j = await apiGet('/api/v1/models/active-config');
// DB 解析失败时后端返回 HTTP 200 + 业务码 500必须检查 code
// 否则 cfgEntries 恒为空、cfgError 也为空,页面永远显示「加载中…」假死
if (j.code !== 200) throw new Error(j.message || '解析生效配置失败');
cfgEntries.value = Object.entries(j.data || {}).map(([k, v]) => [k, String(v)]);
} catch (e) {
cfgEntries.value = [];
cfgError.value = '加载失败:' + e.message;
} finally { loadingCfg.value = false; }
try {
const j = await apiGet('/api/v1/agent/config');
agentCfg.value = j.data;
} catch (e) { /* 静默 */ }
try {
const j = await apiGet('/api/v1/models/routes');
routeEntries.value = Object.entries(j.routes || {});
} catch (e) { /* 静默 */ }
}
function invalidateCache() {
ElementPlus.ElMessageBox.confirm(
'将清空 Go 端 LLM 配置缓存,下次请求立刻从数据库重新读取生效配置。确认执行?',
'失效配置缓存', { type: 'warning' },
).then(async () => {
try {
const j = await apiPost('/api/v1/models/invalidate-cache');
ElementPlus.ElMessage.success(j.message || '已失效');
loadConfig();
} catch (e) {
ElementPlus.ElMessage.error('操作失败:' + e.message);
}
}).catch(() => {});
}
// ============================================================
// 知识库模块(自 /kb/view 并入,接口不变:/api/v1/kb/admin/*
// 请求复用本页 apiFetch同一口令头 + 401 自动重弹口令框),
// 返回结构是 {code, message, data},这里再包一层业务码校验
// ============================================================
const KB_API = '/api/v1/kb/admin';
async function kbApi(path, options = {}) {
const json = await apiFetch(KB_API + path, options);
if (json.code !== 200) throw new Error(json.message || '请求失败');
return json.data;
}
let kbLoadedOnce = false;
const libraries = ref([]);
const kbLoading = reactive({ libs: false, docs: false, chunks: false, import: false, search: false, create: false });
const currentLib = ref(null);
const docs = ref([]);
const chunks = ref([]);
const chunksDrawer = reactive({ visible: false, title: '' });
const isDragOver = ref(false);
const fileInput = ref(null);
async function loadLibraries() {
kbLoading.libs = true;
kbLoadedOnce = true;
try {
libraries.value = await kbApi('/libraries');
} catch (e) {
ElementPlus.ElMessage.error('加载库列表失败: ' + e.message);
} finally { kbLoading.libs = false; }
}
// ----- 库 CRUD -----
const kbDialog = reactive({ createLib: false });
const createForm = reactive({ name: '', description: '', source: 'manual' });
function openCreateLib() {
createForm.name = ''; createForm.description = ''; createForm.source = 'manual';
kbDialog.createLib = true;
}
async function submitCreateLib() {
if (!createForm.name.trim()) { ElementPlus.ElMessage.warning('请填库名'); return; }
kbLoading.create = true;
try {
await kbApi('/libraries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
});
ElementPlus.ElMessage.success('知识库已创建');
kbDialog.createLib = false;
await loadLibraries();
} catch (e) {
ElementPlus.ElMessage.error('创建失败: ' + e.message);
} finally { kbLoading.create = false; }
}
function onLibCommand(cmd, lib) {
if (cmd === 'search') { jumpToKBSearch(lib.id); }
else if (cmd === 'delete') { deleteLibrary(lib); }
}
// 从库卡片/库详情跳到检索页并预选该库
function jumpToKBSearch(libId) {
searchForm.library_id = libId;
switchPage('kbsearch');
}
async function deleteLibrary(lib) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定删除「${lib.name}」?该库下 ${lib.doc_count} 篇文档和 ${lib.chunk_count} 个分段都会被软删除。`,
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
);
} catch { return; }
try {
await kbApi('/libraries/' + lib.id, { method: 'DELETE' });
ElementPlus.ElMessage.success('已删除');
await loadLibraries();
} catch (e) {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
// ----- 进入/退出库详情 -----
async function openLibrary(lib) {
currentLib.value = lib;
await loadDocs(lib.id);
}
function exitLibrary() {
currentLib.value = null;
docs.value = [];
loadLibraries();
}
async function loadDocs(libId) {
kbLoading.docs = true;
try { docs.value = await kbApi('/libraries/' + libId + '/docs'); }
catch (e) { ElementPlus.ElMessage.error('加载文档失败: ' + e.message); }
finally { kbLoading.docs = false; }
}
// ----- 文件导入 -----
function triggerFileInput() {
if (!kbLoading.import && fileInput.value) fileInput.value.click();
}
function handleFileInput(e) {
const file = e.target.files[0];
if (file) doImport(file);
e.target.value = '';
}
function handleDrop(e) {
isDragOver.value = false;
const file = e.dataTransfer.files[0];
if (file) doImport(file);
}
async function doImport(file) {
if (!currentLib.value) { ElementPlus.ElMessage.warning('请先选择库'); return; }
const ext = '.' + file.name.split('.').pop().toLowerCase();
// 与后端 kb.ParseFileFromBytes 支持的格式保持一致
if (!['.xlsx', '.xls', '.csv', '.md', '.txt', '.pdf', '.docx', '.html', '.htm'].includes(ext)) {
ElementPlus.ElMessage.error('不支持的文件类型(支持 xlsx/xls/csv/md/txt/pdf/docx/html');
return;
}
if (file.size > 50 * 1024 * 1024) {
ElementPlus.ElMessage.error('文件过大(最大 50MB');
return;
}
kbLoading.import = true;
try {
const fd = new FormData();
fd.append('library_id', String(currentLib.value.id));
fd.append('file', file);
// FormData 不能手动设 Content-Type浏览器要自动带 multipart 边界),
// apiFetch 只加口令头,正好满足
const data = await kbApi('/docs/import', { method: 'POST', body: fd });
ElementPlus.ElMessage({
type: 'success',
message: `导入成功!「${file.name}」共切分为 ${data.chunk_count} 个分段`,
duration: 3500,
});
await loadDocs(currentLib.value.id);
await loadLibraries();
} catch (e) {
ElementPlus.ElMessage.error('导入失败: ' + e.message);
} finally { kbLoading.import = false; }
}
async function deleteDoc(doc) {
try {
await ElementPlus.ElMessageBox.confirm(
`确定删除文档「${doc.title}」?所有 ${doc.chunk_count} 个分段会一并软删除。`,
'危险操作',
{ type: 'warning', confirmButtonClass: 'el-button--danger' },
);
} catch { return; }
try {
await kbApi('/docs/' + doc.id, { method: 'DELETE' });
ElementPlus.ElMessage.success('已删除');
await loadDocs(currentLib.value.id);
await loadLibraries();
} catch (e) {
ElementPlus.ElMessage.error('删除失败: ' + e.message);
}
}
// ----- 分段查看/编辑 -----
async function openChunksDrawer(doc) {
chunksDrawer.title = `${doc.title}」分段详情(共 ${doc.chunk_count} 个)`;
chunksDrawer.visible = true;
kbLoading.chunks = true;
chunks.value = [];
try {
chunks.value = await kbApi('/docs/' + doc.id + '/chunks');
} catch (e) {
ElementPlus.ElMessage.error('加载分段失败: ' + e.message);
} finally { kbLoading.chunks = false; }
}
function getRelatedQuestions(meta) {
if (!meta) return '';
try {
const obj = typeof meta === 'string' ? JSON.parse(meta) : meta;
return obj.related_questions ? obj.related_questions.join('') : '';
} catch { return ''; }
}
const editChunkDialog = reactive({ visible: false, saving: false, chunkId: null });
const editChunkForm = reactive({ title: '', content: '', relatedQuestions: [] });
function openEditChunk(chunk) {
editChunkDialog.chunkId = chunk.id;
editChunkForm.title = chunk.title || '';
editChunkForm.content = chunk.content || '';
let qs = [];
if (chunk.meta_json) {
try {
const obj = typeof chunk.meta_json === 'string' ? JSON.parse(chunk.meta_json) : chunk.meta_json;
qs = obj.related_questions ? [...obj.related_questions] : [];
} catch { qs = []; }
}
editChunkForm.relatedQuestions = qs;
editChunkDialog.visible = true;
}
async function saveEditChunk() {
if (!editChunkForm.content.trim()) { ElementPlus.ElMessage.warning('内容不能为空'); return; }
const cleanedQs = editChunkForm.relatedQuestions.map(s => (s || '').trim()).filter(s => s !== '');
editChunkDialog.saving = true;
try {
await kbApi('/chunks/' + editChunkDialog.chunkId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: editChunkForm.title,
content: editChunkForm.content,
related_questions: cleanedQs,
}),
});
ElementPlus.ElMessage.success('已保存');
editChunkDialog.visible = false;
// 刷新分段列表让用户立即看到改动
if (chunksDrawer.visible && chunks.value.length > 0) {
const docId = chunks.value[0].doc_id;
chunks.value = await kbApi('/docs/' + docId + '/chunks');
}
} catch (e) {
ElementPlus.ElMessage.error('保存失败: ' + e.message);
} finally { editChunkDialog.saving = false; }
}
// ----- 全文检索测试 -----
const searchForm = reactive({ library_id: null, query: '', top_k: 5, mode: 'fulltext' });
const searchResults = ref([]);
const searchedOnce = ref(false);
const searchDurationMs = ref(0);
async function runSearch() {
if (!searchForm.library_id) { ElementPlus.ElMessage.warning('请选择知识库'); return; }
if (!searchForm.query.trim()) { ElementPlus.ElMessage.warning('请输入检索词'); return; }
kbLoading.search = true;
const t0 = performance.now();
try {
searchResults.value = await kbApi('/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(searchForm),
});
searchDurationMs.value = Math.round(performance.now() - t0);
searchedOnce.value = true;
if (searchResults.value.length === 0) ElementPlus.ElMessage.info('没有命中相关内容');
} catch (e) {
ElementPlus.ElMessage.error('检索失败: ' + e.message);
} finally { kbLoading.search = false; }
}
function scorePercent(score) {
const max = Math.max(...searchResults.value.map(r => r.score), score, 0.001);
return Math.round((score / max) * 100);
}
function fmtDate(ts) {
if (!ts || ts <= 0) return '';
const d = new Date(ts * 1000);
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
// ---------- 轮询调度 ----------
let timer5s = null;
let timer2s = null;
function refreshAll(silent) {
loadRuns(silent);
loadStats(silent);
}
onMounted(async () => {
applyTheme();
// 先确保有口令(弹框直到输入正确),再开始拉数据
await requirePwd();
refreshAll();
loadConfig();
loadSystem();
pollLogs();
timer5s = setInterval(() => {
if (autoRefresh.value) refreshAll(true);
// 首页和系统页都依赖 system 数据
if (activePage.value === 'system' || activePage.value === 'home') loadSystem();
}, 5000);
timer2s = setInterval(() => {
if (activePage.value === 'logs') pollLogs();
}, 2000);
if (activePage.value === 'stats') nextTick(() => rebuildCharts());
// 刷新后直接停留在知识库页时,也要拉一次库列表
if (activePage.value === 'kb' || activePage.value === 'kbsearch') loadLibraries();
window.addEventListener('resize', onWinResize);
});
onUnmounted(() => {
if (timer5s) clearInterval(timer5s);
if (timer2s) clearInterval(timer2s);
window.removeEventListener('resize', onWinResize);
});
function onWinResize() {
if (durationChart) durationChart.resize();
if (tokenChart) tokenChart.resize();
}
// ---------- 展示辅助 ----------
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts * 1000);
const p = n => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
function fmtMs(ms) {
if (ms == null) return '-';
if (ms < 1000) return ms + 'ms';
return (ms / 1000).toFixed(1) + 's';
}
function fmtUptime(sec) {
if (sec == null) return '-';
if (sec < 3600) return Math.floor(sec / 60) + ' 分钟';
if (sec < 86400) return (sec / 3600).toFixed(1) + ' 小时';
return (sec / 86400).toFixed(1) + ' 天';
}
const STEP_NAMES = {
medical_guard: '医疗守卫',
kb_retrieval: '知识库检索',
plan: '计划Planning',
llm_call: 'LLM 调用',
tool_call: '工具调用',
reflection: '反思审核Reflection)',
};
function stepTypeName(t) { return STEP_NAMES[t] || t; }
return {
isDark, toggleTheme,
pwdDialog, pwdInput, submitPwd, logout,
menus, activePage, currentMenuLabel, switchPage, menuCollapsed, toggleCollapse,
autoRefresh, saveAutoRefresh,
runs, loadingRuns, filterScene, filterStatus, sceneOptions, bufferSize, loadRuns,
drawerVisible, detail, openRunDetail,
stats, successRate, sceneEntries, durationChartEl, tokenChartEl,
logs, logFilter, logAutoScroll, logViewerEl, filteredLogs, colorizeLog, clearLogView,
sys, healthEntries, healthOK,
debugPanels,
guardText, guardLoading, guardResult, runGuardTest,
modelTestProvider, modelTestMessage, modelTestLoading, modelTestResult, runModelTest,
kbTestQuery, kbTestTopK, kbTestLoading, kbTestResult, runKBTest,
enhScene, enhKBEnabled, enhSystem, enhUser, enhLoading, enhResult, runEnhanceTest, showEnhanceTimeline,
cfgEntries, cfgError, loadingCfg, agentCfg, routeEntries, loadConfig, invalidateCache, activeModelText,
fmtTime, fmtMs, fmtUptime, stepTypeName,
// 知识库模块
libraries, kbLoading, currentLib, docs, chunks, chunksDrawer, isDragOver, fileInput,
kbDialog, createForm, openCreateLib, submitCreateLib, onLibCommand, jumpToKBSearch, deleteLibrary,
openLibrary, exitLibrary, triggerFileInput, handleFileInput, handleDrop, deleteDoc,
openChunksDrawer, getRelatedQuestions, editChunkDialog, editChunkForm, openEditChunk, saveEditChunk,
searchForm, searchResults, searchedOnce, searchDurationMs, runSearch, scorePercent, fmtDate,
};
},
});
// 全局注册 Element Plus 图标组件(不注册的话所有 <el-icon> 都是空白——上个版本踩的坑)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn }).mount('#app');
</script>
</body>
</html>