Initial commit

This commit is contained in:
liujing
2026-07-16 15:42:50 +08:00
commit 535b03712e
33 changed files with 7893 additions and 0 deletions
+13
View File
@@ -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>
+1523
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+430
View File
@@ -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>
+119
View File
@@ -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 }
+28
View File
@@ -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')
+209
View File
@@ -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>
+324
View File
@@ -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>
+607
View File
@@ -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>
+269
View File
@@ -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>
+15
View File
@@ -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,
},
},
},
})