Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能体观测中心</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1523
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "agent-session-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-plus": "^2.9.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="theme">
|
||||
<!-- Toast -->
|
||||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="mobile-menu-btn" @click="mobileSidebar = !mobileSidebar">☰</button>
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">
|
||||
<span class="logo-symbol">⌘</span>
|
||||
<span class="logo-ping"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="logo-title-row">
|
||||
<h1 class="logo-title">智能体观测中心</h1>
|
||||
<span class="logo-badge">混合引擎</span>
|
||||
</div>
|
||||
<p class="logo-sub">Codex · Claude · Agy 协同会话追踪平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engine Switcher (desktop) -->
|
||||
<div class="engine-switcher">
|
||||
<button
|
||||
v-for="eng in engineOptions"
|
||||
:key="eng.key"
|
||||
class="engine-btn"
|
||||
:class="{ active: selectedEngine === eng.key }"
|
||||
:style="selectedEngine === eng.key ? { background: getEngineMeta(eng.key).color, borderColor: getEngineMeta(eng.key).color } : {}"
|
||||
@click="setEngine(eng.key)"
|
||||
>
|
||||
<span v-html="eng.icon"></span>
|
||||
<span>{{ eng.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-stats">
|
||||
<div>
|
||||
<span class="stat-label-top">当前观测会话</span>
|
||||
<span class="stat-num-top">{{ filteredSessionCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="stat-label-top">累计调用量</span>
|
||||
<span class="stat-num-top accent">{{ filteredCallCount.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="theme-btn" @click="toggleTheme">
|
||||
{{ theme === 'dark' ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
|
||||
<button class="refresh-btn" :class="{ loading: refreshing }" @click="handleRefresh" :disabled="refreshing">
|
||||
<span class="refresh-icon" :class="{ spin: refreshing }">↻</span>
|
||||
<span>{{ refreshing ? '同步中...' : '同步状态' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-body">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" :class="{ open: mobileSidebar }">
|
||||
<nav class="sidebar-nav">
|
||||
<p class="nav-label-top">控制菜单</p>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="nav-btn"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="navigateTo(tab.key)"
|
||||
>
|
||||
<span class="nav-icon" v-html="tab.icon"></span>
|
||||
<span class="nav-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.key === 'sessions'" class="nav-badge">{{ filteredSessionCount }}</span>
|
||||
<span v-else-if="tab.key === 'tools'" class="nav-badge">{{ totalTools }}</span>
|
||||
<span v-else class="nav-dot"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<p class="nav-label-top">引擎全局监测</p>
|
||||
<div class="status-card">
|
||||
<div v-for="eng in engineStatusList" :key="eng.key" class="status-row">
|
||||
<span class="status-dot" :style="{ background: eng.color }" :class="{ pulse: eng.available }"></span>
|
||||
<span class="status-name">{{ eng.label }} 实例</span>
|
||||
<span class="status-val" :style="{ color: eng.color }">{{ eng.status }}</span>
|
||||
</div>
|
||||
<div class="status-divider"></div>
|
||||
<div class="status-row">
|
||||
<span class="status-name">本地沙箱隔离</span>
|
||||
<span class="status-val secure">SECURE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="footer-version">
|
||||
<span class="footer-dot"></span>
|
||||
<span class="footer-txt">联机节点 {{ cliVersion || 'v1.0' }}</span>
|
||||
</div>
|
||||
<span class="footer-addr">127.0.0.1:{{ port }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
<div v-if="mobileSidebar" class="sidebar-overlay" @click="mobileSidebar = false"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main-content">
|
||||
<!-- Engine Switcher (mobile) -->
|
||||
<div class="engine-switcher-mobile">
|
||||
<button
|
||||
v-for="eng in engineOptions"
|
||||
:key="eng.key"
|
||||
class="engine-btn"
|
||||
:class="{ active: selectedEngine === eng.key }"
|
||||
:style="selectedEngine === eng.key ? { background: getEngineMeta(eng.key).color, borderColor: getEngineMeta(eng.key).color } : {}"
|
||||
@click="setEngine(eng.key)"
|
||||
>{{ eng.short }}</button>
|
||||
</div>
|
||||
|
||||
<router-view
|
||||
:selected-engine="selectedEngine"
|
||||
@update:selectedEngine="setEngine"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getHealth, getSessions, getGlobalTools, refreshCache, getEngineMeta, ENGINE_META } from './api/index.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const theme = ref(localStorage.getItem('codex-theme') || 'dark')
|
||||
const refreshing = ref(false)
|
||||
const mobileSidebar = ref(false)
|
||||
const toast = ref(null)
|
||||
const toastTimer = ref(null)
|
||||
const healthOk = ref(false)
|
||||
const sessionCount = ref(0)
|
||||
const filteredSessionCount = ref(0)
|
||||
const filteredCallCount = ref(0)
|
||||
const totalTools = ref(0)
|
||||
const cliVersion = ref('')
|
||||
const port = ref('3721')
|
||||
const selectedEngine = ref(localStorage.getItem('codex-engine') || 'all')
|
||||
const engineHealth = ref({})
|
||||
|
||||
const engineOptions = [
|
||||
{ key: 'all', label: '全部引擎', icon: '⊞', short: '全部' },
|
||||
{ key: 'codex', label: 'Codex', icon: '⚡', short: 'Codex' },
|
||||
{ key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' },
|
||||
{ key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ key: 'sessions', label: '智能体会话列表', icon: '⊞' },
|
||||
{ key: 'tools', label: '工具链与技能集', icon: '⚙' },
|
||||
{ key: 'analytics', label: '效能分析报表', icon: '📊' },
|
||||
]
|
||||
|
||||
const engineStatusList = computed(() => {
|
||||
const result = []
|
||||
for (const [key, meta] of Object.entries(ENGINE_META)) {
|
||||
const h = engineHealth.value[key] || {}
|
||||
const count = h.session_count || 0
|
||||
const sessionAvailable = h.session_source_available !== undefined ? h.session_source_available : true
|
||||
const hasCli = h.cli_available !== undefined ? h.cli_available : h.available
|
||||
let statusText
|
||||
if (count > 0) {
|
||||
statusText = `${count} 组就绪`
|
||||
} else if (hasCli) {
|
||||
statusText = '数据源不可用'
|
||||
} else {
|
||||
statusText = '不可用'
|
||||
}
|
||||
result.push({
|
||||
key,
|
||||
color: meta.color,
|
||||
label: meta.label,
|
||||
available: sessionAvailable || hasCli,
|
||||
status: statusText,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const activeTab = computed(() => {
|
||||
const p = route.path
|
||||
if (p === '/sessions' || p.startsWith('/sessions/')) return 'sessions'
|
||||
if (p === '/tools') return 'tools'
|
||||
if (p === '/analytics') return 'analytics'
|
||||
return 'sessions'
|
||||
})
|
||||
|
||||
function navigateTo(key) {
|
||||
mobileSidebar.value = false
|
||||
router.push(`/${key}`)
|
||||
}
|
||||
|
||||
function setEngine(eng) {
|
||||
selectedEngine.value = eng
|
||||
localStorage.setItem('codex-engine', eng)
|
||||
showToast(eng === 'all' ? '正在查看全量智能体引擎' : `已切换到 ${ENGINE_META[eng]?.label || eng} 智能体`)
|
||||
// Reload current view with new engine filter
|
||||
router.replace(route.path)
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('codex-theme', theme.value)
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
toast.value = msg
|
||||
if (toastTimer.value) clearTimeout(toastTimer.value)
|
||||
toastTimer.value = setTimeout(() => { toast.value = null }, 3000)
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const data = await getHealth()
|
||||
healthOk.value = data.status === 'ok'
|
||||
sessionCount.value = data.session_count || 0
|
||||
engineHealth.value = data.engines || {}
|
||||
if (data.engines?.codex?.session_count) {
|
||||
totalTools.value = 0
|
||||
}
|
||||
updateStats()
|
||||
} catch { healthOk.value = false }
|
||||
}
|
||||
|
||||
async function updateStats() {
|
||||
try {
|
||||
const s = await getSessions({ engine: selectedEngine.value, limit: 1 })
|
||||
filteredSessionCount.value = s.total || 0
|
||||
const t = await getGlobalTools({ engine: selectedEngine.value })
|
||||
filteredCallCount.value = t.tool_usage?.reduce((a, b) => a + b.total_calls, 0) || 0
|
||||
totalTools.value = t.total_unique_tools || 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await refreshCache()
|
||||
await checkHealth()
|
||||
showToast('会话数据库状态同步成功,已更新至最新时间戳!')
|
||||
} catch {
|
||||
showToast('刷新失败')
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Provide engine selection to child views
|
||||
provide('selectedEngine', selectedEngine)
|
||||
|
||||
onMounted(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
checkHealth()
|
||||
})
|
||||
|
||||
watch(theme, (val) => {
|
||||
document.documentElement.setAttribute('data-theme', val)
|
||||
})
|
||||
|
||||
watch(selectedEngine, () => {
|
||||
updateStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root, [data-theme="dark"] {
|
||||
--bg-body: #0B0F19;
|
||||
--bg-sidebar: #0E1322;
|
||||
--bg-header: #0F172A;
|
||||
--bg-card: #0E1322;
|
||||
--bg-card-hover: #1a1f33;
|
||||
--bg-input: #0f172a;
|
||||
--border: #1e293b;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #6366f1;
|
||||
--accent-light: #818cf8;
|
||||
--accent-bg: rgba(99,102,241,0.1);
|
||||
--accent-border: rgba(99,102,241,0.2);
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--header-height: 56px;
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg-body: #f1f5f9;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-header: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f8fafc;
|
||||
--bg-input: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body, #app { height: 100%; width: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans SC', sans-serif; background: var(--bg-body); color: var(--text-primary); }
|
||||
code, .mono { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; top: 12px; right: 12px; z-index: 9999;
|
||||
padding: 10px 18px; border-radius: 10px;
|
||||
background: var(--accent-bg); border: 1px solid var(--accent-border);
|
||||
color: var(--accent-light); font-size: 13px; font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: var(--header-height); padding: 0 20px;
|
||||
background: var(--bg-header); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; z-index: 100; gap: 12px;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
|
||||
.mobile-menu-btn { display: none; background: none; border: none; color: var(--text-secondary); cursor: pointer; font-size: 18px; }
|
||||
.logo-area { display: flex; align-items: center; gap: 10px; }
|
||||
.logo-icon { position: relative; width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; border-radius: 10px; background: linear-gradient(135deg, var(--accent), #7c3aed); color: #fff; font-size: 18px; flex-shrink: 0; }
|
||||
.logo-ping { position: absolute; top: -2px; right: -2px; width: 8px; height: 8px; border-radius: 50%; background: var(--success); animation: ping 2s infinite; }
|
||||
@keyframes ping { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
.logo-title-row { display: flex; align-items: center; gap: 6px; }
|
||||
.logo-title { font-size: 15px; font-weight: 700; color: var(--text-primary); }
|
||||
.logo-badge { padding: 1px 8px; border-radius: 10px; font-size: 9px; font-weight: 600; background: var(--accent-bg); color: var(--accent-light); border: 1px solid var(--accent-border); }
|
||||
.logo-sub { font-size: 10px; color: var(--text-muted); }
|
||||
|
||||
/* Engine Switcher */
|
||||
.engine-switcher { display: flex; gap: 4px; padding: 3px; border-radius: 10px; background: rgba(15,23,42,0.5); border: 1px solid var(--border); }
|
||||
[data-theme="light"] .engine-switcher { background: var(--bg-body); }
|
||||
.engine-btn { display: flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 8px; border: none; background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.12s; white-space: nowrap; }
|
||||
.engine-btn:hover { color: var(--text-secondary); }
|
||||
.engine-btn.active { color: #fff; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.header-stats { display: flex; gap: 12px; padding-right: 12px; border-right: 1px solid var(--border); }
|
||||
.header-stats > div { text-align: right; }
|
||||
.stat-label-top { display: block; font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); }
|
||||
.stat-num-top { font-size: 14px; font-weight: 700; font-family: 'SF Mono', monospace; color: var(--text-primary); }
|
||||
.stat-num-top.accent { color: var(--accent-light); }
|
||||
.theme-btn { width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; font-size: 16px; }
|
||||
.theme-btn:hover { background: var(--accent-bg); }
|
||||
.refresh-btn { display: flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 8px; border: none; background: linear-gradient(135deg, var(--accent), #7c3aed); color: #fff; font-size: 11px; font-weight: 600; cursor: pointer; }
|
||||
.refresh-btn:hover { opacity: 0.9; }
|
||||
.refresh-btn:disabled { opacity: 0.6; }
|
||||
.refresh-icon { display: inline-block; }
|
||||
.refresh-icon.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Body */
|
||||
.app-body { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar { width: var(--sidebar-width); background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; overflow-y: auto; }
|
||||
.sidebar-nav { padding: 16px 12px; flex: 1; }
|
||||
.nav-label-top { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); padding: 0 8px 10px; }
|
||||
.nav-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; margin-bottom: 2px; border-radius: 8px; border: none; background: transparent; color: var(--text-secondary); font-size: 13px; font-weight: 500; cursor: pointer; text-align: left; transition: all 0.12s; }
|
||||
.nav-btn:hover { background: rgba(255,255,255,0.04); color: var(--text-primary); }
|
||||
.nav-btn.active { background: var(--accent-bg); color: var(--accent-light); border-left: 2px solid var(--accent); font-weight: 600; }
|
||||
[data-theme="light"] .nav-btn.active { color: var(--accent); }
|
||||
.nav-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.nav-label { flex: 1; }
|
||||
.nav-badge { padding: 1px 8px; border-radius: 10px; font-size: 10px; font-family: 'SF Mono', monospace; background: var(--accent-bg); color: var(--accent-light); border: 1px solid var(--accent-border); }
|
||||
.nav-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
|
||||
|
||||
.sidebar-status { padding: 0 12px 12px; }
|
||||
.status-card { padding: 12px; border-radius: 8px; background: rgba(15,23,42,0.4); border: 1px solid var(--border); }
|
||||
[data-theme="light"] .status-card { background: var(--bg-body); }
|
||||
.status-row { display: flex; align-items: center; gap: 6px; font-size: 10px; margin-bottom: 6px; }
|
||||
.status-row:last-child { margin-bottom: 0; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-dot.pulse { animation: pulse 1.5s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.status-name { flex: 1; color: var(--text-secondary); }
|
||||
.status-val { font-family: 'SF Mono', monospace; font-weight: 600; font-size: 10px; }
|
||||
.status-val.secure { color: var(--success); }
|
||||
.status-divider { height: 1px; background: var(--border); margin: 6px 0; }
|
||||
|
||||
.sidebar-footer { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-top: 1px solid var(--border); font-size: 10px; }
|
||||
.footer-version { display: flex; align-items: center; gap: 5px; }
|
||||
.footer-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
|
||||
.footer-txt { color: var(--text-muted); }
|
||||
.footer-addr { font-family: 'SF Mono', monospace; color: var(--accent-light); font-weight: 600; }
|
||||
|
||||
.main-content { flex: 1; overflow-y: auto; padding: 24px; min-width: 0; }
|
||||
.engine-switcher-mobile { display: none; gap: 4px; margin-bottom: 12px; }
|
||||
.engine-switcher-mobile .engine-btn { flex: 1; justify-content: center; padding: 8px 4px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; }
|
||||
.engine-switcher-mobile .engine-btn.active { color: #fff; border: none; }
|
||||
.sidebar-overlay { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn { display: block; }
|
||||
.engine-switcher { display: none; }
|
||||
.engine-switcher-mobile { display: flex; }
|
||||
.header-stats { display: none; }
|
||||
.sidebar { position: fixed; top: var(--header-height); left: 0; bottom: 0; z-index: 200; transform: translateX(-100%); transition: transform 0.25s ease; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 199; }
|
||||
.sidebar-status { display: none; }
|
||||
.sidebar-footer { display: none; }
|
||||
.main-content { padding: 16px; }
|
||||
.refresh-btn span:last-child { display: none; }
|
||||
.refresh-btn { padding: 7px 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
const state = reactive({
|
||||
health: null,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
async function request(path, opts = {}) {
|
||||
state.loading = true
|
||||
try {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
...opts,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error || res.statusText)
|
||||
}
|
||||
return await res.json()
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHealth() {
|
||||
const data = await request('/health')
|
||||
state.health = data
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getSessions({ engine = 'all', query, status, from, to, limit, offset } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (engine) params.set('engine', engine)
|
||||
if (query) params.set('query', query)
|
||||
if (status) params.set('status', status)
|
||||
if (from) params.set('from', String(from))
|
||||
if (to) params.set('to', String(to))
|
||||
if (limit) params.set('limit', String(limit))
|
||||
if (offset) params.set('offset', String(offset))
|
||||
const qs = params.toString()
|
||||
return request(`/sessions${qs ? '?' + qs : ''}`)
|
||||
}
|
||||
|
||||
export async function getSessionDetail(engine, id) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export async function getSessionTools(engine, id) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(id)}/tools`)
|
||||
}
|
||||
|
||||
export async function getGlobalTools({ engine = 'all' } = {}) {
|
||||
return request(`/tools?engine=${engine}`)
|
||||
}
|
||||
|
||||
export async function resolveShortId(prefix, engine = 'all') {
|
||||
return request(`/resolve/${encodeURIComponent(prefix)}?engine=${engine}`)
|
||||
}
|
||||
|
||||
export async function refreshCache(engines = null) {
|
||||
return request('/refresh', {
|
||||
method: 'POST',
|
||||
body: engines ? JSON.stringify({ engines }) : undefined,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function setCustomName(engine, sessionId, name) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/custom-name`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCustomName(engine, sessionId) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/custom-name`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateTitle(engine, sessionId) {
|
||||
return request(`/sessions/${encodeURIComponent(engine)}/${encodeURIComponent(sessionId)}/generate-title`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = typeof ts === 'number' && ts < 1e15 ? new Date(ts * 1000) : new Date(ts)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
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(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// Engine color/icon mapping
|
||||
export const ENGINE_META = {
|
||||
codex: { color: '#3b82f6', bg: 'rgba(59,130,246,0.1)', label: 'Codex', border: 'rgba(59,130,246,0.2)', icon: '⚡' },
|
||||
claude: { color: '#a855f7', bg: 'rgba(168,85,247,0.1)', label: 'Claude', border: 'rgba(168,85,247,0.2)', icon: '🧠' },
|
||||
agy: { color: '#10b981', bg: 'rgba(16,185,129,0.1)', label: 'Agy', border: 'rgba(16,185,129,0.2)', icon: '⚙' },
|
||||
}
|
||||
|
||||
export function getEngineMeta(engine) {
|
||||
return ENGINE_META[engine] || { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: engine, border: 'rgba(99,102,241,0.2)', icon: '?' }
|
||||
}
|
||||
|
||||
export { state }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import App from './App.vue'
|
||||
import SessionList from './views/SessionList.vue'
|
||||
import SessionDetail from './views/SessionDetail.vue'
|
||||
import ToolsView from './views/ToolsView.vue'
|
||||
import AnalyticsView from './views/AnalyticsView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/sessions' },
|
||||
{ path: '/sessions', name: 'sessions', component: SessionList },
|
||||
{ path: '/sessions/:id', name: 'session-detail', component: SessionDetail, props: true },
|
||||
{ path: '/tools', name: 'tools', component: ToolsView },
|
||||
{ path: '/analytics', name: 'analytics', component: AnalyticsView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="analytics-view">
|
||||
<div class="analytics-stats">
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">周峰值调用</p>
|
||||
<div class="stat-main-row">
|
||||
<span class="stat-big accent">{{ weeklyPeak }}</span>
|
||||
<span class="stat-change" :class="trendDir">{{ weeklyChange }}</span>
|
||||
</div>
|
||||
<p class="stat-extra">{{ weeklyPeakDate }} 记录峰值并发 ({{ engineLabel }})</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">累计会话数</p>
|
||||
<div class="stat-main-row">
|
||||
<span class="stat-big">{{ totalSessions }}</span>
|
||||
</div>
|
||||
<p class="stat-extra">{{ engineLabel }}</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">异常/中断率</p>
|
||||
<div class="stat-main-row">
|
||||
<span class="stat-big success">{{ errorRate }}%</span>
|
||||
</div>
|
||||
<p class="stat-extra">基于最近 200 条会话 ({{ engineLabel }})。异常/中断率 {{ errorRate }}%。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3 class="card-title">调用趋势 (7 天)</h3>
|
||||
<p class="card-subtitle">{{ engineLabel }} · 会话与工具调用活跃度</p>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="legend-item"><span class="legend-dot calls-dot"></span>工具调用</span>
|
||||
<span class="legend-item"><span class="legend-dot sess-dot"></span>会话数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<svg v-if="chartData.length" :viewBox="`0 0 ${svgW} ${svgH}`" class="chart-svg">
|
||||
<line v-for="(_, i) in [0,1,2,3]" :key="'g'+i" :x1="padL" :y1="gridY(i)" :x2="svgW - padR" :y2="gridY(i)" stroke="var(--border)" stroke-width="1" />
|
||||
<path :d="callsPath" fill="url(#callsGrad)" opacity="0.15" />
|
||||
<polyline :points="callsLine" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<polyline :points="sessLine" fill="none" stroke="var(--accent-light)" stroke-width="1.5" stroke-dasharray="4 3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<defs><linearGradient id="callsGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.3" /><stop offset="100%" stop-color="var(--accent)" stop-opacity="0" /></linearGradient></defs>
|
||||
<text v-for="(d, i) in chartData" :key="'x'+i" :x="xPos(i)" :y="svgH - 8" text-anchor="middle" fill="var(--text-muted)" font-size="10">{{ d.label }}</text>
|
||||
</svg>
|
||||
<div v-else class="chart-empty">暂无趋势数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown -->
|
||||
<div class="breakdown-grid">
|
||||
<div class="card">
|
||||
<h4 class="card-title-sm">会话状态分布</h4>
|
||||
<div class="bar-list">
|
||||
<div class="bar-item">
|
||||
<div class="bar-label"><span>已完成</span><span class="bar-pct accent">{{ successPct }}%</span></div>
|
||||
<div class="bar-track"><div class="bar-fill bar-success" :style="'width:' + successPct + '%'"></div></div>
|
||||
</div>
|
||||
<div class="bar-item">
|
||||
<div class="bar-label"><span>已中断/异常</span><span class="bar-pct danger">{{ errorPct }}%</span></div>
|
||||
<div class="bar-track"><div class="bar-fill bar-danger" :style="'width:' + errorPct + '%'"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h4 class="card-title-sm">观测报告</h4>
|
||||
<p class="report-text">
|
||||
基于 {{ totalSessions }} 个会话 ({{ engineLabel }})。异常/中断率 {{ errorRate }}%。
|
||||
Agy 暂无本地会话数据源。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, inject, watch } from 'vue'
|
||||
import { getSessions, getGlobalTools } from '../api/index.js'
|
||||
|
||||
const selectedEngine = inject('selectedEngine', ref('all'))
|
||||
|
||||
const sessions = ref([])
|
||||
const totalSessions = ref(0)
|
||||
const errorCount = ref(0)
|
||||
const engineLabel = computed(() => {
|
||||
const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy' }
|
||||
return labels[selectedEngine.value] || selectedEngine.value
|
||||
})
|
||||
|
||||
const successRate = computed(() => totalSessions.value ? Math.round((totalSessions.value - errorCount.value) / totalSessions.value * 100) : 0)
|
||||
const errorRate = computed(() => totalSessions.value ? Math.round(errorCount.value / totalSessions.value * 100) : 0)
|
||||
const successPct = computed(() => successRate.value)
|
||||
const errorPct = computed(() => errorRate.value)
|
||||
|
||||
// Chart dimensions
|
||||
const svgW = 600, svgH = 220, padL = 40, padR = 20, padT = 10, padB = 30
|
||||
const chartW = computed(() => svgW - padL - padR)
|
||||
const chartH = computed(() => svgH - padT - padB)
|
||||
|
||||
function gridY(i) { return padT + chartH.value * (1 - i / 3) }
|
||||
|
||||
const chartData = computed(() => {
|
||||
const dayMap = {}
|
||||
const now = Date.now()
|
||||
const seven = now - 7 * 86400000
|
||||
const list = sessions.value || []
|
||||
list.forEach(s => {
|
||||
// created_at is in ms from unified API
|
||||
const ts = s.created_at
|
||||
if (!ts || ts < seven) return
|
||||
const d = new Date(ts)
|
||||
const key = `${d.getMonth() + 1}/${d.getDate()}`
|
||||
if (!dayMap[key]) dayMap[key] = { sessions: 0, calls: 0 }
|
||||
dayMap[key].sessions++
|
||||
dayMap[key].calls += s.tool_call_count || 0
|
||||
})
|
||||
const days = []
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now - i * 86400000)
|
||||
const key = `${d.getMonth() + 1}/${d.getDate()}`
|
||||
days.push({ label: key, ...dayMap[key] || { sessions: 0, calls: 0 } })
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
const maxC = computed(() => Math.max(...chartData.value.map(d => d.calls), 1))
|
||||
const maxS = computed(() => Math.max(...chartData.value.map(d => d.sessions), 1))
|
||||
|
||||
function xPos(i) { return padL + (chartW.value / Math.max(chartData.value.length - 1, 1)) * i }
|
||||
function callsY(v) { return padT + chartH.value * (1 - v / maxC.value) }
|
||||
function sessY(v) { return padT + chartH.value * (1 - v / maxS.value) }
|
||||
|
||||
const callsLine = computed(() => chartData.value.map((d, i) => `${xPos(i)},${callsY(d.calls)}`).join(' '))
|
||||
const sessLine = computed(() => chartData.value.map((d, i) => `${xPos(i)},${sessY(d.sessions)}`).join(' '))
|
||||
const callsPath = computed(() => {
|
||||
const pts = chartData.value
|
||||
if (!pts.length) return ''
|
||||
let d = `M ${xPos(0)},${callsY(pts[0].calls)}`
|
||||
for (let i = 1; i < pts.length; i++) d += ` L ${xPos(i)},${callsY(pts[i].calls)}`
|
||||
d += ` L ${xPos(pts.length - 1)},${svgH - padB} L ${xPos(0)},${svgH - padB} Z`
|
||||
return d
|
||||
})
|
||||
|
||||
const weeklyPeak = computed(() => Math.max(...chartData.value.map(d => d.calls), 0))
|
||||
const weeklyPeakDate = computed(() => { const p = weeklyPeak.value; const item = chartData.value.find(d => d.calls === p); return item ? item.label : '' })
|
||||
const weeklyChange = computed(() => {
|
||||
if (chartData.value.length < 2) return ''
|
||||
const l = chartData.value[chartData.value.length - 1].calls
|
||||
const p = chartData.value[chartData.value.length - 2].calls
|
||||
if (!p) return '+100%'
|
||||
return ((l - p) / p * 100).toFixed(1) > 0 ? '+' + ((l - p) / p * 100).toFixed(1) + '%' : ((l - p) / p * 100).toFixed(1) + '%'
|
||||
})
|
||||
const trendDir = computed(() => weeklyChange.value.startsWith('+') ? 'trend-up' : 'trend-down')
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const s = await getSessions({ engine: selectedEngine.value, limit: 200 })
|
||||
sessions.value = s.sessions || []
|
||||
totalSessions.value = s.sessions?.length || 0
|
||||
errorCount.value = (s.sessions || []).filter(x => x.status === 'error' || x.status === 'interrupted').length
|
||||
} catch { console.error('analytics load error') }
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
watch(selectedEngine, loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-view { width: 100%; }
|
||||
.analytics-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { padding: 20px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||
.stat-main-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
|
||||
.stat-big { font-size: 28px; font-weight: 800; font-family: 'SF Mono', monospace; color: var(--text-primary); }
|
||||
.stat-big.accent { color: var(--accent-light); }
|
||||
.stat-big.success { color: var(--success); }
|
||||
.stat-change { font-size: 12px; font-weight: 600; font-family: 'SF Mono', monospace; }
|
||||
.trend-up { color: var(--success); }
|
||||
.trend-down { color: var(--danger); }
|
||||
.stat-extra { font-size: 10px; color: var(--text-muted); }
|
||||
.card { border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); padding: 20px; margin-bottom: 16px; }
|
||||
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; color: var(--text-primary); }
|
||||
.card-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.card-title-sm { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
|
||||
.legend { display: flex; gap: 16px; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; font-family: 'SF Mono', monospace; color: var(--text-secondary); }
|
||||
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.calls-dot { background: var(--accent); }
|
||||
.sess-dot { background: var(--accent-light); }
|
||||
.chart-wrap { width: 100%; height: 240px; }
|
||||
.chart-svg { width: 100%; height: 100%; }
|
||||
.chart-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-muted); font-size: 13px; }
|
||||
.breakdown-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 768px) { .breakdown-grid { grid-template-columns: 1fr; } }
|
||||
.bar-item { margin-bottom: 12px; }
|
||||
.bar-label { display: flex; justify-content: space-between; font-size: 12px; font-family: 'SF Mono', monospace; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.bar-pct { font-weight: 700; }
|
||||
.bar-pct.accent { color: var(--success); }
|
||||
.bar-pct.danger { color: var(--danger); }
|
||||
.bar-track { height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; }
|
||||
.bar-success { background: var(--success); }
|
||||
.bar-danger { background: var(--danger); }
|
||||
.report-text { font-size: 12px; color: var(--text-secondary); line-height: 1.6; }
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="session-detail-page">
|
||||
<button class="back-btn" @click="goBack">← 返回列表</button>
|
||||
|
||||
<div v-if="loadError" class="error-state">
|
||||
<p>{{ loadError }}</p>
|
||||
<button class="back-btn" @click="loadDetail">重试</button>
|
||||
<button class="back-btn" style="margin-left:8px" @click="goBack">返回列表</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && !session" class="error-state">
|
||||
<p>会话不存在</p>
|
||||
<button class="back-btn" @click="goBack">返回列表</button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="detail-layout" v-else>
|
||||
<div v-if="session" class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">会话详情 ({{ engineLabel }})</h3>
|
||||
<button class="action-btn" @click="copyResume">
|
||||
📋 复制 <code>{{ resumeCmd }}</code>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Custom Name -->
|
||||
<div v-if="session.metadata" class="name-section">
|
||||
<div class="name-row">
|
||||
<h4 class="name-title">{{ session.metadata.custom_name || session.metadata.title || session.metadata.summary || '(无消息)' }}</h4>
|
||||
<button class="name-btn" @click="openNameEditor">✏️</button>
|
||||
<button v-if="session.metadata.custom_name" class="name-btn" @click="clearName">🗑</button>
|
||||
<button class="name-btn" @click="generateAITitle" :disabled="aiLoading" :title="aiLoading ? '生成中...' : 'AI 生成标题'">
|
||||
<span v-if="aiLoading">⟳</span><span v-else>🤖</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="session.metadata.custom_name" class="name-subtitle">{{ session.metadata.title || session.metadata.summary }}</p>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">会话 ID</span>
|
||||
<code class="meta-val mono">{{ session.metadata?.id || routeId }}</code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">引擎</span>
|
||||
<span class="meta-val" :style="{ color: meta.color }">{{ meta.label }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">创建时间</span>
|
||||
<span class="meta-val">{{ formatTime(session.metadata?.created_at) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">工作目录</span>
|
||||
<span class="meta-val mono accent">{{ session.metadata?.cwd || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">模型</span>
|
||||
<span class="meta-val">{{ session.metadata?.model || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">CLI 版本</span>
|
||||
<span class="meta-val">{{ session.metadata?.cli_version || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-lbl">状态</span>
|
||||
<span class="meta-val">{{ statusText(session.metadata?.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="topTools.length" class="section">
|
||||
<h4 class="section-title">使用的工具</h4>
|
||||
<div class="tool-bars">
|
||||
<div v-for="([name, count]) in topTools" :key="name" class="tool-bar-item">
|
||||
<span class="tool-name">{{ name }}</span>
|
||||
<div class="tool-track"><div class="tool-fill" :style="{ width: (count / maxCalls * 100) + '%' }"></div></div>
|
||||
<span class="tool-count">{{ count }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="session" class="card">
|
||||
<div class="card-title-row">
|
||||
<h4 class="card-title">消息时间线 ({{ messages.length }})</h4>
|
||||
<span v-if="messages.length >= 300" class="note-tag">仅显示前 300 条</span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div v-for="(msg, i) in messages" :key="i" class="tl-item">
|
||||
<div class="tl-dot" :class="'dot-' + msgTypeClass(msg.type)"></div>
|
||||
<div class="tl-content">
|
||||
<div class="tl-meta">
|
||||
<span class="tl-tag" :class="'tl-tag-' + msgTypeClass(msg.type)">{{ msgTypeLabel(msg.type) }}</span>
|
||||
<span class="tl-time">{{ formatTime(msg.time) }}</span>
|
||||
<span v-if="msg.phase" class="tl-phase">{{ msg.phase }}</span>
|
||||
<span v-if="msg.name" class="tl-name mono">{{ msg.name }}</span>
|
||||
</div>
|
||||
<div v-if="msg.message" class="tl-msg">{{ msg.message }}</div>
|
||||
<div v-if="msg.arguments" class="tl-code mono">{{ msg.arguments }}</div>
|
||||
<div v-if="msg.output_preview" class="tl-code mono dim">{{ msg.output_preview }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!messages.length && session?.metadata?.has_detail_db === false" class="tl-empty">该会话的详细 Trace 数据存储在 per-conversation protobuf 中,本地详情缓存不可用。</div>
|
||||
<div v-else-if="!messages.length" class="tl-empty">暂无消息</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name Editor Dialog -->
|
||||
<div v-if="editNameVisible" class="dialog-overlay" @click.self="editNameVisible = false">
|
||||
<div class="dialog">
|
||||
<div class="dialog-head">
|
||||
<h3>自定义名称</h3>
|
||||
<button class="dialog-close" @click="editNameVisible = false">✕</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">为本会话设置一个易记的名称,仅在本地使用。</p>
|
||||
<input v-model="editNameValue" class="dialog-input" placeholder="输入自定义名称..." maxlength="100" @keyup.enter="saveName" />
|
||||
<p class="dialog-counter">{{ editNameValue.length }}/100</p>
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="dialog-btn" @click="editNameVisible = false">取消</button>
|
||||
<button class="dialog-btn primary" @click="saveName">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSessionDetail, formatTimestamp, getEngineMeta, setCustomName, deleteCustomName, generateTitle } from '../api/index.js'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const routeId = computed(() => props.id || route.params.id)
|
||||
const engine = computed(() => route.query.engine || 'codex')
|
||||
const meta = computed(() => getEngineMeta(engine.value))
|
||||
const engineLabel = computed(() => meta.value.label)
|
||||
const resumeCmd = computed(() => engine.value === 'codex' ? `codex resume ${routeId.value}` : engine.value === 'claude' ? `claude --resume ${routeId.value}` : `agy --conversation ${routeId.value}`)
|
||||
|
||||
const session = ref(null)
|
||||
const messages = ref([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref(null)
|
||||
const editNameVisible = ref(false)
|
||||
const editNameValue = ref('')
|
||||
const aiLoading = ref(false)
|
||||
|
||||
const topTools = computed(() => {
|
||||
if (!session.value?.summary?.tool_calls) return []
|
||||
return Object.entries(session.value.summary.tool_calls).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
})
|
||||
const maxCalls = computed(() => { if (!topTools.value.length) return 1; return topTools.value[0][1] })
|
||||
|
||||
function statusText(s) {
|
||||
return { success: '已完成', error: '异常', interrupted: '中断', active: '执行中' }[s] || s || '-'
|
||||
}
|
||||
|
||||
function msgTypeClass(type) {
|
||||
return { user_message: 'primary', agent_message: 'success', function_call: 'warning', function_call_output: 'info', assistant_message: 'success', error: 'danger', interrupted: 'warning' }[type] || ''
|
||||
}
|
||||
function msgTypeLabel(type) {
|
||||
return { user_message: '用户', agent_message: 'AI', function_call: '工具', function_call_output: '输出', assistant_message: 'AI消息', error: '错误', interrupted: '中断' }[type] || type
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await getSessionDetail(engine.value, routeId.value)
|
||||
if (data?.metadata?.error) {
|
||||
loadError.value = data.metadata.error
|
||||
} else {
|
||||
session.value = data
|
||||
messages.value = data.messages || []
|
||||
}
|
||||
} catch (err) {
|
||||
loadError.value = '加载失败: ' + (err.message || '')
|
||||
ElMessage.error('加载会话详情失败: ' + (err.message || ''))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() { router.push('/sessions') }
|
||||
function copyResume() {
|
||||
navigator.clipboard.writeText(resumeCmd.value).then(() => ElMessage.success(`已复制: ${resumeCmd.value}`))
|
||||
.catch(() => ElMessage.success(`手动复制: ${resumeCmd.value}`))
|
||||
}
|
||||
function formatTime(ts) { return formatTimestamp(ts) }
|
||||
|
||||
function openNameEditor() {
|
||||
editNameValue.value = session.value?.metadata?.custom_name || ''
|
||||
editNameVisible.value = true
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
const name = editNameValue.value.trim()
|
||||
try {
|
||||
if (!name) {
|
||||
await deleteCustomName(engine.value, routeId.value)
|
||||
} else {
|
||||
await setCustomName(engine.value, routeId.value, name)
|
||||
}
|
||||
// Reload to show updated name
|
||||
await loadDetail()
|
||||
editNameVisible.value = false
|
||||
ElMessage.success('名称已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearName() {
|
||||
try {
|
||||
await deleteCustomName(engine.value, routeId.value)
|
||||
await loadDetail()
|
||||
ElMessage.success('名称已清除')
|
||||
} catch (err) {
|
||||
ElMessage.error('清除失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAITitle() {
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const result = await generateTitle(engine.value, routeId.value)
|
||||
if (result.title) {
|
||||
editNameValue.value = result.title
|
||||
editNameVisible.value = true
|
||||
ElMessage.success('标题已生成,可点击保存确认')
|
||||
} else {
|
||||
ElMessage.warning(result.error || '生成失败')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('生成失败: ' + err.message)
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
|
||||
// Reload when route params change
|
||||
watch(
|
||||
() => [route.params.id, route.query.engine],
|
||||
() => { if (route.params.id) loadDetail(); }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.session-detail-page { width: 100%; }
|
||||
.back-btn { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 12px; padding: 6px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.back-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.card { border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); padding: 20px; margin-bottom: 16px; }
|
||||
.card-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; color: var(--text-primary); }
|
||||
.action-btn { display: flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--accent-border); background: var(--accent-bg); color: var(--accent-light); font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.action-btn:hover { background: var(--accent); color: #fff; }
|
||||
.action-btn code { font-size: 11px; }
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
|
||||
.meta-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.meta-lbl { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.meta-val { font-size: 13px; color: var(--text-primary); }
|
||||
.meta-val.accent { color: var(--accent-light); }
|
||||
.section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.tool-bars { display: flex; flex-direction: column; gap: 6px; }
|
||||
.tool-bar-item { display: flex; align-items: center; gap: 12px; }
|
||||
.tool-name { min-width: 130px; font-size: 12px; color: var(--accent-light); font-family: 'SF Mono', monospace; }
|
||||
.tool-track { flex: 1; height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
|
||||
.tool-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--accent), var(--accent-light)); }
|
||||
.tool-count { width: 40px; text-align: right; font-size: 11px; font-family: 'SF Mono', monospace; color: var(--text-secondary); }
|
||||
.timeline { }
|
||||
.tl-item { display: flex; gap: 12px; padding: 6px 0; border-left: 2px solid var(--border); margin-left: 6px; padding-left: 20px; position: relative; }
|
||||
.tl-dot { position: absolute; left: -8px; top: 10px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-muted); border: 2px solid var(--bg-card); }
|
||||
.dot-primary { background: var(--accent); }
|
||||
.dot-success { background: var(--success); }
|
||||
.dot-warning { background: var(--warning); }
|
||||
.dot-danger { background: var(--danger); }
|
||||
.dot-info { background: var(--text-muted); }
|
||||
.tl-content { flex: 1; min-width: 0; }
|
||||
.tl-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.tl-tag { padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: 600; text-transform: uppercase; }
|
||||
.tl-tag-primary { background: var(--accent-bg); color: var(--accent-light); }
|
||||
.tl-tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||||
.tl-tag-warning { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||||
.tl-tag-danger { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.tl-time { font-size: 10px; color: var(--text-muted); }
|
||||
.tl-phase { font-size: 10px; color: #8b5cf6; }
|
||||
.tl-name { font-size: 11px; color: var(--warning); }
|
||||
.tl-msg { font-size: 13px; color: var(--text-primary); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
||||
.tl-code { font-size: 11px; color: var(--text-secondary); background: rgba(15,23,42,0.3); padding: 4px 8px; border-radius: 4px; margin-top: 2px; max-height: 60px; overflow: hidden; white-space: pre-wrap; word-break: break-all; }
|
||||
.tl-code.dim { color: var(--text-muted); }
|
||||
.tl-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.note-tag { font-size: 10px; padding: 2px 8px; border-radius: 4px; background: var(--accent-bg); color: var(--accent-light); }
|
||||
.error-state { padding: 48px; text-align: center; }
|
||||
|
||||
/* Name section */
|
||||
.name-section { margin-bottom: 16px; }
|
||||
.name-row { display: flex; align-items: center; gap: 6px; }
|
||||
.name-title { font-size: 15px; font-weight: 600; color: var(--text-primary); line-height: 1.4; }
|
||||
.name-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.name-btn { background: none; border: none; font-size: 14px; cursor: pointer; opacity: 0.5; padding: 2px; }
|
||||
.name-btn:hover { opacity: 1; }
|
||||
|
||||
/* Dialog */
|
||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 600; display: flex; align-items: center; justify-content: center; }
|
||||
.dialog { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; width: 90%; max-width: 420px; }
|
||||
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.dialog-head h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.dialog-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.dialog-input { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.dialog-input:focus { border-color: var(--accent); }
|
||||
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.dialog-btn:hover { opacity: 0.9; }
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<template>
|
||||
<div class="sessions-view">
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">活跃会话集群</p>
|
||||
<h3 class="stat-val">{{ activeCount }}/{{ total }}</h3>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">当前筛选调用量</p>
|
||||
<h3 class="stat-val accent">{{ filteredCalls.toLocaleString() }} <span class="stat-unit">次</span></h3>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">引擎分布情况</p>
|
||||
<div class="engine-dots">
|
||||
<span v-for="(c, eng) in engineDist" :key="eng" class="engine-dot" :style="{ background: getEngineMeta(eng).color }" :title="`${getEngineMeta(eng).label}: ${c}`">{{ getEngineMeta(eng).label }}({{ c }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-lbl">异常/中断率</p>
|
||||
<h3 class="stat-val success">{{ errorRate }}%</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filter -->
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-ico">🔍</span>
|
||||
<input v-model="query" class="search-input" placeholder="输入摘要、会话 ID、文件路径关键字检索..." @keyup.enter="doSearch" />
|
||||
<button v-if="query" class="search-clear" @click="query = ''; doSearch()">✕</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-lbl">状态过滤:</span>
|
||||
<button v-for="opt in statusOpts" :key="opt.key" class="filter-btn" :class="{ active: statusFilter === opt.key }" @click="statusFilter = opt.key; doSearch()">{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Cards -->
|
||||
<div class="session-list">
|
||||
<div class="list-header" v-if="sessions.length">
|
||||
<span class="list-header-text">已过滤出 {{ displayedSessions.length }} 个智能体会话监控流 (Engine: {{ selectedEngine.toUpperCase() }})</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="!displayedSessions.length" class="empty-state">
|
||||
<p>无符合当前引擎或状态过滤条件的会话实例。</p>
|
||||
</div>
|
||||
<div v-else class="card-grid">
|
||||
<div
|
||||
v-for="s in displayedSessions"
|
||||
:key="s.key || s.id"
|
||||
class="session-card"
|
||||
:style="{ borderLeftColor: getEngineMeta(s.engine).color }"
|
||||
@click="openDrawer(s)"
|
||||
>
|
||||
<div class="card-main">
|
||||
<div class="card-tags">
|
||||
<span class="engine-badge" :style="{ background: getEngineMeta(s.engine).bg, color: getEngineMeta(s.engine).color, borderColor: getEngineMeta(s.engine).border }">
|
||||
{{ s.engine_label || getEngineMeta(s.engine).label }}
|
||||
</span>
|
||||
<span class="status-badge" :class="'status-' + (s.status || 'unknown')">
|
||||
<span class="status-dot-card" :class="'dot-' + (s.status || 'unknown')"></span>
|
||||
{{ statusLabel(s.status) }}
|
||||
</span>
|
||||
<span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span>
|
||||
<button class="name-btn-card" @click.stop="openNameEditor(s)" title="自定义名称">✏️</button>
|
||||
</div>
|
||||
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
|
||||
<div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div>
|
||||
<div class="card-path">
|
||||
<span class="path-ico">📁</span>
|
||||
<span class="path-text">{{ s.cwd || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="meta-col">
|
||||
<span class="meta-col-label">对应版本</span>
|
||||
<span class="meta-col-val">{{ s.cli_version || s.model || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta-col">
|
||||
<span class="meta-col-label">工具调用</span>
|
||||
<span class="meta-col-val calls" :class="callsClass(s.tool_call_count)">{{ s.tool_call_count || 0 }} 次</span>
|
||||
</div>
|
||||
<div class="meta-col">
|
||||
<span class="meta-col-label">开始时间</span>
|
||||
<span class="meta-col-val time">{{ formatTimestamp(s.created_at) }}</span>
|
||||
</div>
|
||||
<div class="meta-col">
|
||||
<button class="detail-btn" @click.stop="goToDetailPage(s)" title="进入详情页">详情 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" v-if="total > pageSize">
|
||||
<span class="page-info">共 {{ total }} 条</span>
|
||||
<div class="page-btns">
|
||||
<button class="page-btn" :disabled="page <= 1" @click="goPage(page - 1)">‹</button>
|
||||
<button v-for="p in pageNums" :key="p" v-if="p !== '...'" class="page-btn" :class="{ active: p === page }" @click="goPage(p)">{{ p }}</button>
|
||||
<span v-else class="page-ellipsis">...</span>
|
||||
<button class="page-btn" :disabled="page >= maxPage" @click="goPage(page + 1)">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Drawer -->
|
||||
<div v-if="drawerSession" class="drawer-overlay" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title-bar">
|
||||
<span class="drawer-badge" :style="{ background: getEngineMeta(drawerSession.engine).bg, color: getEngineMeta(drawerSession.engine).color, borderColor: getEngineMeta(drawerSession.engine).border }">
|
||||
{{ getEngineMeta(drawerSession.engine).label }} · 运行诊断
|
||||
</span>
|
||||
<button class="drawer-close" @click="closeDrawer">✕</button>
|
||||
</div>
|
||||
<h3 class="drawer-summary">{{ drawerSession.custom_name || drawerSession.summary || '(无消息)' }}</h3>
|
||||
<div v-if="drawerSession.custom_name" class="drawer-original-summary">{{ drawerSession.summary }}</div>
|
||||
<div class="drawer-name-row">
|
||||
<p class="drawer-id mono">完整会话 UUID: {{ drawerSession.id }}</p>
|
||||
<button class="name-edit-btn" @click.stop="openNameEditor(drawerSession)">✏️ 命名</button>
|
||||
<button class="name-edit-btn" @click.stop="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</button>
|
||||
<button v-if="drawerSession.custom_name" class="name-edit-btn" @click.stop="clearCustomName(drawerSession)">🗑 清除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body" v-if="drawerDetail">
|
||||
<!-- detail content unchanged -->
|
||||
<div class="drawer-meta-box">
|
||||
<div class="meta-row"><span class="meta-lbl">宿主执行用户:</span><span class="meta-val">{{ drawerSession.model || '-' }}</span></div>
|
||||
<div class="meta-row"><span class="meta-lbl">引擎运行时版本:</span><span class="meta-val">{{ drawerSession.cli_version || '-' }}</span></div>
|
||||
<div class="meta-row"><span class="meta-lbl">工作物理路径:</span><span class="meta-val accent mono">{{ drawerSession.cwd || '-' }}</span></div>
|
||||
<div class="meta-row"><span class="meta-lbl">运行总时长:</span><span class="meta-val">{{ drawerSession.duration_ms ? Math.round(drawerSession.duration_ms / 60000) + ' 分钟' : '暂无数据' }}</span></div>
|
||||
<div class="meta-row"><span class="meta-lbl">诊断执行状态:</span><span class="meta-val">
|
||||
<span class="status-tag-drawer" :class="'tag-' + (drawerSession.status || 'unknown')">{{ statusLabel(drawerSession.status) }}</span>
|
||||
</span></div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-section">
|
||||
<h4 class="drawer-section-title">交互式智能体思考环与工具调用 Trace 链路</h4>
|
||||
<div v-if="!drawerDetail.messages?.length && drawerSession?.engine === 'agy' && drawerSession?.has_detail_db === false" class="trace-empty">该会话的详细 Trace 数据存储在 per-conversation protobuf 中,本地详情缓存不可用。</div>
|
||||
<div v-else-if="!drawerDetail.messages?.length" class="trace-empty">该历史会话没有持久化的 Trace 调试详情数据。</div>
|
||||
<div v-else class="trace-list">
|
||||
<div v-for="(msg, i) in drawerDetail.messages.slice(0, 30)" :key="i" class="trace-item">
|
||||
<span class="trace-dot" :class="traceDot(msg.type)"></span>
|
||||
<div class="trace-content">
|
||||
<span class="trace-tag" :class="'trace-tag-' + traceTag(msg.type)">{{ traceLabel(msg.type) }}</span>
|
||||
<span class="trace-msg">{{ msg.message || msg.name || msg.output_preview || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="drawerError" class="drawer-body">
|
||||
<div class="drawer-error-box">
|
||||
<p class="drawer-error-text">{{ drawerError }}</p>
|
||||
<button class="drawer-retry-btn" @click="retryDrawer">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!drawerDetail && !drawerError" class="drawer-body">
|
||||
<div class="loading-state">加载中...</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<button class="footer-btn-copy" @click="copyResume(drawerSession)">
|
||||
📋 复制恢复命令
|
||||
</button>
|
||||
<button class="footer-btn-uuid" @click="copyUUID(drawerSession.id)">
|
||||
📋 复制 UUID 句柄
|
||||
</button>
|
||||
<button class="footer-btn-detail" @click="goToDetailPage(drawerSession)">
|
||||
→ 进入详情页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name Editor Dialog -->
|
||||
<div v-if="editNameVisible" class="dialog-overlay" @click.self="editNameVisible = false">
|
||||
<div class="dialog">
|
||||
<div class="dialog-head">
|
||||
<h3>自定义名称</h3>
|
||||
<button class="dialog-close" @click="editNameVisible = false">✕</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">为本会话设置一个易记的名称,仅在本地使用。</p>
|
||||
<input
|
||||
v-model="editNameValue"
|
||||
class="dialog-input"
|
||||
placeholder="输入自定义名称..."
|
||||
maxlength="100"
|
||||
@keyup.enter="saveCustomName"
|
||||
/>
|
||||
<p class="dialog-counter">{{ editNameValue.length }}/100</p>
|
||||
</div>
|
||||
<div class="dialog-foot">
|
||||
<button class="dialog-btn" @click="editNameVisible = false">取消</button>
|
||||
<button class="dialog-btn primary" @click="saveCustomName">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, inject, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle } from '../api/index.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
|
||||
|
||||
const sessions = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const page = ref(1)
|
||||
const pageSize = 50
|
||||
const drawerSession = ref(null)
|
||||
const drawerDetail = ref(null)
|
||||
const drawerError = ref(null)
|
||||
const drawerAiLoading = ref(false)
|
||||
const router = useRouter()
|
||||
const activeSessions = ref(0)
|
||||
const filteredCalls = ref(0)
|
||||
|
||||
const statusOpts = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'active', label: '执行中' },
|
||||
{ key: 'success', label: '已完成' },
|
||||
{ key: 'failed', label: '已中断' },
|
||||
]
|
||||
|
||||
const engineDist = computed(() => {
|
||||
const dist = {}
|
||||
for (const s of sessions.value) {
|
||||
dist[s.engine] = (dist[s.engine] || 0) + 1
|
||||
}
|
||||
return dist
|
||||
})
|
||||
|
||||
const errorCount = computed(() => sessions.value.filter(s => s.status === 'error' || s.status === 'interrupted').length)
|
||||
const errorRate = computed(() => sessions.value.length ? Math.round(errorCount.value / sessions.value.length * 100) : 0)
|
||||
const activeCount = computed(() => sessions.value.filter(s => s.status === 'active').length)
|
||||
|
||||
const displayedSessions = computed(() => {
|
||||
let list = sessions.value
|
||||
if (statusFilter.value === 'success') list = list.filter(s => s.status === 'success')
|
||||
else if (statusFilter.value === 'failed') list = list.filter(s => s.status === 'error' || s.status === 'interrupted')
|
||||
else if (statusFilter.value === 'active') list = list.filter(s => s.status === 'active')
|
||||
return list
|
||||
})
|
||||
|
||||
const maxPage = computed(() => Math.ceil(total.value / pageSize))
|
||||
const pageNums = computed(() => {
|
||||
const p = [], tp = maxPage.value
|
||||
if (tp <= 7) { for (let i = 1; i <= tp; i++) p.push(i) }
|
||||
else {
|
||||
p.push(1)
|
||||
if (page.value > 3) p.push('...')
|
||||
for (let i = Math.max(2, page.value - 1); i <= Math.min(tp - 1, page.value + 1); i++) p.push(i)
|
||||
if (page.value < tp - 2) p.push('...')
|
||||
p.push(tp)
|
||||
}
|
||||
return p
|
||||
})
|
||||
|
||||
function statusLabel(status) {
|
||||
return { success: '已成功', error: '已中断', interrupted: '已中断', active: '执行中', unknown: '未知' }[status] || status
|
||||
}
|
||||
|
||||
function callsClass(c) {
|
||||
if (c > 100) return 'calls-danger'
|
||||
if (c > 50) return 'calls-warn'
|
||||
return 'calls-normal'
|
||||
}
|
||||
|
||||
function traceTag(type) {
|
||||
const map = { user_message: 'primary', function_call: 'warning', function_call_output: 'info', assistant_message: 'success' }
|
||||
return map[type] || ''
|
||||
}
|
||||
function traceDot(type) {
|
||||
const map = { user_message: 'dot-primary', function_call: 'dot-warning', function_call_output: 'dot-info', assistant_message: 'dot-success' }
|
||||
return map[type] || 'dot-default'
|
||||
}
|
||||
function traceLabel(type) {
|
||||
const map = { user_message: '用户', function_call: '工具', function_call_output: '输出', assistant_message: 'AI' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Map frontend status values to backend status values
|
||||
let apiStatus = statusFilter.value;
|
||||
if (apiStatus === 'all') apiStatus = null;
|
||||
// 'failed' is handled by backend as error+interrupted
|
||||
|
||||
const data = await getSessions({
|
||||
engine: props.selectedEngine,
|
||||
query: query.value,
|
||||
status: apiStatus,
|
||||
limit: pageSize,
|
||||
offset: (page.value - 1) * pageSize,
|
||||
})
|
||||
sessions.value = data.sessions || []
|
||||
total.value = data.total || 0
|
||||
// Also fetch stats for calls
|
||||
const tools = await getGlobalTools({ engine: props.selectedEngine })
|
||||
filteredCalls.value = tools.tool_usage?.reduce((a, b) => a + b.total_calls, 0) || 0
|
||||
} catch (err) {
|
||||
ElMessage.error('加载失败: ' + err.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function doSearch() { page.value = 1; loadSessions() }
|
||||
function goPage(p) { if (p >= 1 && p <= maxPage.value) { page.value = p; loadSessions() } }
|
||||
|
||||
async function openDrawer(s) {
|
||||
drawerSession.value = s
|
||||
drawerDetail.value = null
|
||||
drawerError.value = null
|
||||
try {
|
||||
const data = await getSessionDetail(s.engine, s.id)
|
||||
drawerDetail.value = data
|
||||
} catch (err) {
|
||||
drawerError.value = '加载详情失败: ' + (err.message || '')
|
||||
}
|
||||
}
|
||||
|
||||
function retryDrawer() {
|
||||
if (drawerSession.value) openDrawer(drawerSession.value)
|
||||
}
|
||||
|
||||
function closeDrawer() { drawerSession.value = null; drawerDetail.value = null; drawerError.value = null }
|
||||
|
||||
function copyResume(s) {
|
||||
if (!s.resume_command) return
|
||||
navigator.clipboard.writeText(s.resume_command).then(() => {
|
||||
ElMessage.success(`已复制: ${s.resume_command}`)
|
||||
}).catch(() => { ElMessage.success(`手动复制: ${s.resume_command}`) })
|
||||
}
|
||||
|
||||
function copyUUID(id) {
|
||||
navigator.clipboard.writeText(id).then(() => ElMessage.success('UUID 已复制'))
|
||||
}
|
||||
|
||||
// Custom name editing
|
||||
const editNameVisible = ref(false)
|
||||
const editNameValue = ref('')
|
||||
const editNameTarget = ref(null)
|
||||
|
||||
function openNameEditor(session) {
|
||||
editNameTarget.value = session
|
||||
editNameValue.value = session.custom_name || ''
|
||||
editNameVisible.value = true
|
||||
}
|
||||
|
||||
async function saveCustomName() {
|
||||
const s = editNameTarget.value
|
||||
if (!s) return
|
||||
try {
|
||||
const result = await setCustomName(s.engine, s.id, editNameValue.value)
|
||||
// Update local state
|
||||
s.custom_name = result.custom_name || null
|
||||
// Also update drawer session if same
|
||||
if (drawerSession.value?.id === s.id && drawerSession.value?.engine === s.engine) {
|
||||
drawerSession.value.custom_name = s.custom_name
|
||||
}
|
||||
editNameVisible.value = false
|
||||
ElMessage.success('名称已保存')
|
||||
} catch (err) {
|
||||
ElMessage.error('保存失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCustomName(session) {
|
||||
try {
|
||||
await deleteCustomName(session.engine, session.id)
|
||||
session.custom_name = null
|
||||
if (drawerSession.value?.id === session.id && drawerSession.value?.engine === session.engine) {
|
||||
drawerSession.value.custom_name = null
|
||||
}
|
||||
ElMessage.success('名称已清除')
|
||||
} catch (err) {
|
||||
ElMessage.error('清除失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function goToDetailPage(session) {
|
||||
if (!session) return
|
||||
closeDrawer()
|
||||
router.push({ name: 'session-detail', params: { id: session.id }, query: { engine: session.engine } })
|
||||
}
|
||||
|
||||
async function generateDrawerTitle() {
|
||||
const s = drawerSession.value
|
||||
if (!s) return
|
||||
drawerAiLoading.value = true
|
||||
try {
|
||||
const result = await generateTitle(s.engine, s.id)
|
||||
if (result.title) {
|
||||
editNameTarget.value = s
|
||||
editNameValue.value = result.title
|
||||
editNameVisible.value = true
|
||||
ElMessage.success('标题已生成,可点击保存确认')
|
||||
} else {
|
||||
ElMessage.warning(result.error || '生成失败')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('生成失败: ' + err.message)
|
||||
} finally {
|
||||
drawerAiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
|
||||
onMounted(loadSessions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sessions-view { width: 100%; }
|
||||
|
||||
/* Stats */
|
||||
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); }
|
||||
.stat-lbl { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.stat-val { font-size: 22px; font-weight: 800; font-family: 'SF Mono', monospace; color: var(--text-primary); }
|
||||
.stat-val.accent { color: var(--accent-light); }
|
||||
.stat-val.success { color: var(--success); }
|
||||
.stat-unit { font-size: 13px; font-weight: 400; color: var(--text-muted); }
|
||||
.engine-dots { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
|
||||
.engine-dot { font-size: 10px; padding: 1px 6px; border-radius: 4px; color: #fff; font-family: 'SF Mono', monospace; }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||||
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 480px; }
|
||||
.search-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; opacity: 0.5; }
|
||||
.search-input { width: 100%; padding: 9px 32px 9px 36px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.search-input:focus { border-color: var(--accent); }
|
||||
.search-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; }
|
||||
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.filter-lbl { font-size: 11px; color: var(--text-muted); }
|
||||
.filter-btn { padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; }
|
||||
.filter-btn:hover { background: var(--accent-bg); }
|
||||
.filter-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-light); font-weight: 600; }
|
||||
|
||||
/* Session Cards */
|
||||
.session-list { }
|
||||
.list-header { margin-bottom: 12px; }
|
||||
.list-header-text { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||||
|
||||
.loading-state { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
|
||||
.empty-state { padding: 48px; text-align: center; color: var(--text-muted); border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); font-size: 13px; }
|
||||
|
||||
.card-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.session-card {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border);
|
||||
background: var(--bg-card); cursor: pointer; transition: all 0.12s;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.session-card:hover { border-color: var(--text-muted); background: var(--bg-card-hover); }
|
||||
.card-main { flex: 1; min-width: 0; padding-right: 16px; }
|
||||
.card-tags { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.engine-badge { padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; border: 1px solid; font-family: 'SF Mono', monospace; }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 600; }
|
||||
.status-badge.status-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||||
.status-badge.status-error { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.status-badge.status-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||||
.status-badge.status-active { background: rgba(59,130,246,0.1); color: #3b82f6; animation: pulse 1.5s infinite; }
|
||||
.status-badge.status-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||||
.status-dot-card { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.dot-success { background: var(--success); }
|
||||
.dot-error { background: var(--danger); }
|
||||
.dot-interrupted { background: var(--warning); }
|
||||
.dot-active { background: #3b82f6; }
|
||||
.dot-unknown { background: var(--text-muted); }
|
||||
.id-preview { font-size: 10px; color: var(--text-muted); }
|
||||
.id-highlight { color: var(--accent-light); }
|
||||
.name-btn-card { background: none; border: none; font-size: 12px; cursor: pointer; opacity: 0.4; padding: 0 2px; }
|
||||
.name-btn-card:hover { opacity: 1; }
|
||||
.card-summary { font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.4; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-original-summary { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-path { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--text-muted); }
|
||||
.path-ico { font-size: 12px; }
|
||||
.path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', monospace; }
|
||||
|
||||
.card-meta { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
|
||||
.meta-col { text-align: center; }
|
||||
.meta-col-label { display: block; font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
|
||||
.meta-col-val { font-size: 11px; font-family: 'SF Mono', monospace; color: var(--text-secondary); }
|
||||
.meta-col-val.time { font-size: 10px; }
|
||||
.meta-col-val.calls { font-weight: 600; padding: 1px 8px; border-radius: 10px; }
|
||||
.calls-danger { color: var(--danger); background: rgba(239,68,68,0.1); }
|
||||
.calls-warn { color: var(--warning); background: rgba(245,158,11,0.1); }
|
||||
.calls-normal { color: var(--text-secondary); background: rgba(100,116,139,0.1); }
|
||||
.arrow { font-size: 18px; color: var(--text-muted); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; padding: 12px; }
|
||||
.page-info { font-size: 12px; color: var(--text-muted); }
|
||||
.page-btns { display: flex; gap: 4px; align-items: center; }
|
||||
.page-btn { min-width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; font-family: 'SF Mono', monospace; cursor: pointer; }
|
||||
.page-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.page-btn:disabled { opacity: 0.4; }
|
||||
.page-ellipsis { padding: 0 4px; color: var(--text-muted); }
|
||||
|
||||
/* Drawer */
|
||||
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 500; display: flex; justify-content: flex-end; }
|
||||
.drawer { width: 100%; max-width: 560px; height: 100%; background: var(--bg-card); border-left: 1px solid var(--border); display: flex; flex-direction: column; animation: slideIn 0.25s ease; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.drawer-head { padding: 20px; border-bottom: 1px solid var(--border); background: rgba(15,23,42,0.3); }
|
||||
.drawer-title-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.drawer-badge { padding: 3px 10px; border-radius: 4px; font-size: 10px; font-weight: 700; border: 1px solid; font-family: 'SF Mono', monospace; }
|
||||
.drawer-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
|
||||
.drawer-close:hover { color: var(--text-primary); }
|
||||
.drawer-summary { font-size: 14px; font-weight: 600; color: var(--text-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.drawer-id { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.drawer-meta-box { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--border); background: rgba(15,23,42,0.2); margin-bottom: 16px; }
|
||||
.meta-row { display: grid; grid-template-columns: 100px 1fr; gap: 8px; padding: 5px 0; font-size: 11px; }
|
||||
.meta-lbl { color: var(--text-muted); }
|
||||
.meta-val { color: var(--text-primary); font-weight: 500; }
|
||||
.meta-val.accent { color: var(--accent-light); }
|
||||
.status-tag-drawer { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; }
|
||||
.tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||||
.tag-error { background: rgba(239,68,68,0.1); color: var(--danger); }
|
||||
.tag-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||||
.tag-active { background: rgba(59,130,246,0.1); color: #3b82f6; }
|
||||
.tag-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||||
|
||||
.drawer-section { margin-bottom: 16px; }
|
||||
.drawer-section-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.drawer-error-box { padding: 32px; text-align: center; }
|
||||
.drawer-error-text { font-size: 13px; color: var(--danger); margin-bottom: 12px; }
|
||||
.drawer-retry-btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent-bg); color: var(--accent-light); cursor: pointer; font-size: 12px; }
|
||||
.trace-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 12px; border: 1px dashed var(--border); border-radius: 8px; }
|
||||
.trace-list { }
|
||||
.trace-item { display: flex; gap: 8px; padding: 4px 0; align-items: flex-start; }
|
||||
.trace-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
|
||||
.dot-primary { background: var(--accent); }
|
||||
.dot-success { background: var(--success); }
|
||||
.dot-warning { background: var(--warning); }
|
||||
.dot-info { background: var(--text-muted); }
|
||||
.dot-default { background: var(--text-muted); }
|
||||
.trace-content { flex: 1; min-width: 0; }
|
||||
.trace-tag { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: 9px; font-weight: 600; margin-right: 6px; }
|
||||
.trace-tag-primary { background: var(--accent-bg); color: var(--accent-light); }
|
||||
.trace-tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
|
||||
.trace-tag-warning { background: rgba(245,158,11,0.1); color: var(--warning); }
|
||||
.trace-tag-info { background: rgba(100,116,139,0.1); color: var(--text-muted); }
|
||||
.trace-msg { font-size: 10px; color: var(--text-secondary); }
|
||||
|
||||
.drawer-footer { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: rgba(15,23,42,0.2); }
|
||||
.footer-btn-copy, .footer-btn-uuid { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.footer-btn-copy { background: var(--accent); border: none; color: #fff; }
|
||||
.footer-btn-copy:hover { opacity: 0.9; }
|
||||
.footer-btn-uuid { border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); }
|
||||
.footer-btn-uuid:hover { background: var(--bg-card-hover); }
|
||||
|
||||
/* Name editor */
|
||||
.drawer-original-summary { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.drawer-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.name-edit-btn { background: none; border: 1px solid var(--border); padding: 2px 8px; border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer; }
|
||||
.name-edit-btn:hover { border-color: var(--accent); color: var(--accent-light); }
|
||||
.detail-btn { background: var(--accent-bg); border: 1px solid var(--accent-border); padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--accent-light); cursor: pointer; white-space: nowrap; }
|
||||
.detail-btn:hover { background: var(--accent); color: #fff; }
|
||||
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 600; display: flex; align-items: center; justify-content: center; }
|
||||
.dialog { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; width: 90%; max-width: 420px; }
|
||||
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.dialog-head h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.dialog-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
|
||||
.dialog-body { padding: 16px 20px; }
|
||||
.dialog-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.dialog-input { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.dialog-input:focus { border-color: var(--accent); }
|
||||
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
|
||||
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.dialog-btn:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.session-card { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.card-meta { width: 100%; justify-content: space-around; }
|
||||
.drawer { max-width: 100%; }
|
||||
}
|
||||
.footer-btn-detail {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
border: 1px solid var(--accent-border); background: var(--accent-bg);
|
||||
color: var(--accent-light); transition: all 0.12s;
|
||||
}
|
||||
.footer-btn-detail:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.drawer-footer { flex-wrap: wrap; }
|
||||
.footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="tools-view">
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-ico">🔍</span>
|
||||
<input v-model="filter" class="search-input" placeholder="搜索工具、技能或描述..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-grid">
|
||||
<!-- Left: Tool Ranking -->
|
||||
<div class="tools-main">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-left">
|
||||
<span class="card-icon">🏆</span>
|
||||
<div>
|
||||
<h3 class="card-title">工具调用排行</h3>
|
||||
<p class="card-subtitle">跨 {{ maxSessionCount }} 个会话的聚合统计 ({{ engineLabel }})</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="card-badge">{{ filteredTools.length }} 个工具</span>
|
||||
</div>
|
||||
<div class="tool-list">
|
||||
<div v-for="tool in filteredTools" :key="tool.name" class="tool-item" @click="selectedTool = tool">
|
||||
<div class="tool-top">
|
||||
<div class="tool-name-area">
|
||||
<span class="tool-rank" :class="'rank-' + Math.min(tool._rank, 4)">{{ tool._rank }}</span>
|
||||
<span class="tool-name mono">{{ tool.name }}</span>
|
||||
<span v-if="tool.engines?.length" class="tool-engines">{{ tool.engines.join('/') }}</span>
|
||||
</div>
|
||||
<div class="tool-nums">
|
||||
<span class="tool-num">{{ tool.total_calls.toLocaleString() }}</span>
|
||||
<span class="tool-num-label">调用</span>
|
||||
<span class="tool-num sep">{{ tool.session_count }}</span>
|
||||
<span class="tool-num-label">会话</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-bar-track">
|
||||
<div class="tool-bar-fill" :style="{ width: tool._pct + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="tool.sessions?.[0]?.preview" class="tool-ctx">
|
||||
<span class="ctx-label">最近上下文:</span>
|
||||
<span class="ctx-text">{{ tool.sessions[0].preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTool" class="card tool-detail-card">
|
||||
<div class="tool-detail-head">
|
||||
<h4>工具: <code class="mono">{{ selectedTool.name }}</code></h4>
|
||||
<button class="close-btn" @click="selectedTool = null">✕</button>
|
||||
</div>
|
||||
<div class="tool-detail-stats">
|
||||
<span><strong>{{ selectedTool.total_calls.toLocaleString() }}</strong> 总调用</span>
|
||||
<span><strong>{{ selectedTool.session_count }}</strong> 会话</span>
|
||||
<span v-if="selectedTool.engines?.length">引擎: {{ selectedTool.engines.join(', ') }}</span>
|
||||
</div>
|
||||
<h5 class="session-list-title">使用过的会话:</h5>
|
||||
<div v-for="s in (selectedTool.sessions || []).slice(0, 20)" :key="s.session_id" class="session-ref" @click="goToSession(s.session_id, s.engine || selectedEngine.value)">
|
||||
<code class="session-ref-id">{{ (s.session_id || '').substring(0, 31) }}</code>
|
||||
<span class="session-ref-calls">{{ s.calls }}x</span>
|
||||
<span class="session-ref-preview">{{ s.preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Skills & Plugins -->
|
||||
<div class="tools-side">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-left">
|
||||
<span class="card-icon">📋</span>
|
||||
<h3 class="card-title">可用技能</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-list">
|
||||
<div v-for="skill in filteredSkills" :key="skill.name" class="skill-item" :class="{ expanded: expanded === skill.name }">
|
||||
<button class="skill-toggle" @click="expanded = expanded === skill.name ? '' : skill.name">
|
||||
<span class="skill-arrow">{{ expanded === skill.name ? '▼' : '▶' }}</span>
|
||||
<code class="skill-name">{{ skill.name }}</code>
|
||||
</button>
|
||||
<div v-if="expanded === skill.name" class="skill-body">
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-left">
|
||||
<span class="card-icon">🔌</span>
|
||||
<h3 class="card-title">已安装插件</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="available.plugins?.length" class="plugin-list">
|
||||
<div v-for="p in available.plugins" :key="p.name" class="plugin-item">
|
||||
<span class="plugin-dot"></span>
|
||||
<div>
|
||||
<span class="plugin-name">{{ p.name }}</span>
|
||||
<span class="plugin-desc">{{ p.description || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂无插件</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-head-left">
|
||||
<span class="card-icon">💾</span>
|
||||
<h3 class="card-title">缓存工具</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="available.cache_tools?.length" class="cache-grid">
|
||||
<div v-for="ct in available.cache_tools" :key="ct.file" class="cache-item">
|
||||
<div class="cache-header">
|
||||
<span class="cache-name mono">{{ ct.file }}</span>
|
||||
<span class="cache-count">{{ ct.tool_names?.length || 0 }} 个工具</span>
|
||||
</div>
|
||||
<div v-if="ct.tool_names?.length" class="cache-names">
|
||||
<span v-for="tn in ct.tool_names" :key="tn" class="cache-tool-name">{{ tn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂无缓存工具</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, inject, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getGlobalTools } from '../api/index.js'
|
||||
|
||||
const router = useRouter()
|
||||
const selectedEngine = inject('selectedEngine', ref('all'))
|
||||
const loading = ref(true)
|
||||
const tools = ref([])
|
||||
const available = ref({ skills: [], plugins: [], cache_tools: [] })
|
||||
const filter = ref('')
|
||||
const selectedTool = ref(null)
|
||||
const expanded = ref('')
|
||||
const maxSessionCount = ref(0)
|
||||
|
||||
const engineLabel = computed(() => {
|
||||
if (selectedEngine.value === 'all') return '全部引擎'
|
||||
const labels = { codex: 'Codex', claude: 'Claude', agy: 'Agy' }
|
||||
return labels[selectedEngine.value] || selectedEngine.value
|
||||
})
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const max = tools.value[0]?.total_calls || 1
|
||||
return tools.value
|
||||
.filter(t => !filter.value || t.name.toLowerCase().includes(filter.value.toLowerCase()))
|
||||
.map((t, i) => ({ ...t, _rank: i + 1, _pct: t.total_calls / max * 100 }))
|
||||
})
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
const list = available.value.skills || []
|
||||
if (!filter.value) return list
|
||||
const q = filter.value.toLowerCase()
|
||||
return list.filter(s => s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getGlobalTools({ engine: selectedEngine.value })
|
||||
tools.value = data.tool_usage || []
|
||||
available.value = data.available_tools || {}
|
||||
maxSessionCount.value = Math.max(...(data.tool_usage || []).map(t => t.session_count), 0)
|
||||
} catch (err) {
|
||||
console.error('load tools:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToSession(id, engine) {
|
||||
if (id) router.push(`/sessions/${encodeURIComponent(id)}?engine=${engine || 'codex'}`)
|
||||
}
|
||||
|
||||
onMounted(loadTools)
|
||||
watch(selectedEngine, loadTools)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-view { width: 100%; }
|
||||
.search-bar { margin-bottom: 16px; }
|
||||
.search-wrap { position: relative; max-width: 400px; }
|
||||
.search-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; opacity: 0.5; }
|
||||
.search-input { width: 100%; padding: 9px 12px 9px 36px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.search-input:focus { border-color: var(--accent); }
|
||||
.tools-grid { display: grid; grid-template-columns: 1fr 380px; gap: 16px; align-items: start; }
|
||||
@media (max-width: 900px) { .tools-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.card { border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); overflow: hidden; margin-bottom: 16px; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.card-head-left { display: flex; align-items: center; gap: 10px; }
|
||||
.card-icon { font-size: 20px; }
|
||||
.card-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
|
||||
.card-subtitle { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||
.card-badge { padding: 4px 10px; border-radius: 8px; font-size: 11px; font-family: 'SF Mono', monospace; font-weight: 600; background: var(--accent-bg); color: var(--accent-light); border: 1px solid var(--accent-border); }
|
||||
|
||||
.tool-list { padding: 8px 0; max-height: 60vh; overflow-y: auto; }
|
||||
.tool-item { padding: 12px 20px; cursor: pointer; border-bottom: 1px solid var(--border); transition: background 0.1s; }
|
||||
.tool-item:last-child { border-bottom: none; }
|
||||
.tool-item:hover { background: var(--bg-card-hover); }
|
||||
.tool-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.tool-name-area { display: flex; align-items: center; gap: 8px; }
|
||||
.tool-rank { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 5px; font-size: 10px; font-weight: 700; font-family: 'SF Mono', monospace; flex-shrink: 0; }
|
||||
.rank-1 { background: rgba(234,179,8,0.15); color: #eab308; border: 1px solid rgba(234,179,8,0.3); }
|
||||
.rank-2 { background: rgba(148,163,184,0.15); color: #94a3b8; border: 1px solid rgba(148,163,184,0.3); }
|
||||
.rank-3 { background: rgba(180,83,9,0.15); color: #b45309; border: 1px solid rgba(180,83,9,0.3); }
|
||||
.rank-4 { background: rgba(15, 23, 42, 0.4); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
.tool-name { font-size: 12px; font-weight: 600; color: var(--accent-light); }
|
||||
.tool-engines { font-size: 9px; color: var(--text-muted); background: var(--accent-bg); padding: 1px 5px; border-radius: 3px; }
|
||||
.tool-nums { display: flex; align-items: center; gap: 4px; font-family: 'SF Mono', monospace; font-size: 11px; }
|
||||
.tool-num { font-weight: 700; color: var(--text-primary); }
|
||||
.tool-num.sep { margin-left: 6px; }
|
||||
.tool-num-label { font-size: 9px; color: var(--text-muted); }
|
||||
.tool-bar-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; margin-bottom: 4px; }
|
||||
.tool-bar-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--accent), #7c3aed); transition: width 0.3s; }
|
||||
.tool-ctx { display: flex; gap: 4px; font-size: 10px; font-family: 'SF Mono', monospace; color: var(--text-muted); }
|
||||
.ctx-label { flex-shrink: 0; }
|
||||
.ctx-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
|
||||
|
||||
.tool-detail-card { padding: 16px 20px; }
|
||||
.tool-detail-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.tool-detail-head h4 { font-size: 14px; color: var(--text-primary); }
|
||||
.close-btn { background: none; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; }
|
||||
.tool-detail-stats { display: flex; gap: 16px; font-size: 13px; color: var(--text-secondary); margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.session-list-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.session-ref { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 11px; }
|
||||
.session-ref:hover { background: var(--bg-card-hover); margin: 0 -20px; padding: 6px 20px; }
|
||||
.session-ref-id { color: var(--accent-light); font-size: 10px; }
|
||||
.session-ref-calls { color: var(--warning); font-weight: 600; }
|
||||
.session-ref-preview { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.skill-list { }
|
||||
.skill-item { }
|
||||
.skill-toggle { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 20px; border: none; background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.skill-toggle:hover { background: var(--bg-card-hover); }
|
||||
.skill-arrow { font-size: 10px; flex-shrink: 0; }
|
||||
.skill-name { font-size: 12px; font-weight: 600; color: var(--accent-light); flex: 1; }
|
||||
.skill-body { padding: 12px 20px 16px; border-bottom: 1px solid var(--border); background: rgba(15,23,42,0.2); }
|
||||
.skill-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.5; }
|
||||
|
||||
.plugin-list { padding: 12px 20px; }
|
||||
.plugin-item { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); align-items: flex-start; }
|
||||
.plugin-item:last-child { border-bottom: none; }
|
||||
.plugin-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex-shrink: 0; margin-top: 4px; }
|
||||
.plugin-name { display: block; font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||
.plugin-desc { display: block; font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
.cache-grid { padding: 12px 20px; }
|
||||
.cache-item { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: rgba(15,23,42,0.2); margin-bottom: 8px; }
|
||||
.cache-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.cache-name { font-size: 10px; color: var(--text-primary); word-break: break-all; }
|
||||
.cache-count { font-size: 10px; color: var(--text-muted); }
|
||||
.cache-names { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.cache-tool-name { padding: 1px 6px; border-radius: 3px; font-size: 9px; font-family: 'SF Mono', monospace; background: var(--accent-bg); color: var(--accent-light); border: 1px solid var(--accent-border); }
|
||||
.empty-hint { padding: 24px; text-align: center; color: var(--text-muted); font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3722,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3721',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user