feat(frontend): 第二阶段前端 Agent Trace / 主题包 / Mermaid 能力

实现第二阶段分工表中吉海燕负责的 P0/P1 前端能力。

- Agent Trace 可视化:新增 traceService 将扁平事件流折叠为树
  (ModelCallStarted 区间内的工具/文本事件挂为子节点,运行级事件保持顶层),
  TraceTimeline 支持时间线/树两种视图、耗时统计与引用跳转。
- 主题包:新增 themePackageService(Web Mock Adapter),
  校验 manifest 必填字段与 theme_id 格式,拒绝远程 css_entry;
  CSS 侧拒绝 @import / expression() / javascript:,
  未通过校验的 CSS 不会注入页面。内置主题走 data-theme=light|dark|sepia,
  自定义主题走 data-theme={theme_id} + 独立 style 节点。
  ThemesView 增加“已安装/社区主题”两个标签页与导入、预览、卸载流程。
- Mermaid:新增 mermaidService(securityLevel: strict)与 MermaidBlock,
  markdown 渲染管线识别 mermaid 代码块;MarkdownContent 随亮/暗主题重渲染
  (SVG 配色在渲染时烘焙,无法靠 CSS 变量事后调整)。
- 插件贡献 UI:PluginsView 增加“概览/命令/设置”标签页,
  PluginSettingsPanel 按 Schema 动态生成表单;
  secret 字段只写不读,仅展示 configured 状态,不进 store 也不回显。

与 main 上队友成果的整合(rebase 时处理):
- 命令面板保留队友基于真实后端的实现(when 条件求值、效果白名单、
  参数命令跳详情页),仅叠加我新增的主题/任务两条内置命令。
- 删除我先前的 pluginContributionService(mock 版),
  统一改用队友已落地的 pluginService 真实接口;
  相应修正表单以匹配真实契约(options 为 string[]、min/max 可空、无 placeholder)。
- 移除 contracts 中与队友重复的 PluginHostStatus / PluginCommand /
  PluginSettingField / PluginSettingsSchema 声明,以队友版本为准。
- PluginsView 概览页保留队友的 PluginMcpPanel,并补回被我改写时丢掉的空状态。

顺带修复:
- 开启 skipLibCheck —— mermaid 11.17 把 type-fest 泄漏进了发布产物的
  .d.ts,但只声明为自身 devDependency,vue-tsc -b 会因此报错。

验证:pnpm test 26 文件 / 113 测试通过(新增 traceService、
themePackageService 两个测试文件共 22 项);pnpm build 通过。
This commit is contained in:
2026-09-04 21:36:47 +08:00
parent 6bdba2c7f9
commit 639f38c1fc
20 changed files with 4487 additions and 669 deletions
+252
View File
@@ -0,0 +1,252 @@
import type { AgentEvent, TraceNode, TraceNodeType } from '@/contracts'
export function buildTraceNodes(events: AgentEvent[]): TraceNode[] {
const nodes: TraceNode[] = []
let currentModelCallId: string | null = null
for (const event of events) {
const type = mapEventType(event.event)
const id = `seq-${event.sequence}`
const title = getNodeTitle(event)
const subtitle = getNodeSubtitle(event)
const status = getNodeStatus(event)
const node: TraceNode = {
id,
sequence: event.sequence,
type,
title,
subtitle,
status,
data: event.data,
timestamp: event.timestamp,
children: [],
}
if (event.event === 'ModelCallStarted') {
currentModelCallId = id
node.children = []
nodes.push(node)
continue
}
if (event.event === 'ModelCallCompleted' || event.event === 'ModelCallFailed') {
if (currentModelCallId) {
const modelCall = findNodeById(nodes, currentModelCallId)
if (modelCall) {
modelCall.status = event.event === 'ModelCallCompleted' ? 'completed' : 'error'
if (event.data.duration_ms != null) {
modelCall.duration_ms = event.data.duration_ms as number
}
if (event.data.finish_reason) {
modelCall.subtitle = `${modelCall.subtitle ?? ''} · ${String(event.data.finish_reason)}`
}
}
currentModelCallId = null
}
continue
}
if (currentModelCallId && type !== 'run' && type !== 'complete' && type !== 'error') {
const parent = findNodeById(nodes, currentModelCallId)
if (parent) {
node.parent_id = currentModelCallId
parent.children.push(node)
continue
}
}
nodes.push(node)
}
return nodes
}
function findNodeById(nodes: TraceNode[], id: string): TraceNode | null {
for (const node of nodes) {
if (node.id === id) return node
const found = findNodeById(node.children, id)
if (found) return found
}
return null
}
function mapEventType(eventType: AgentEvent['event']): TraceNodeType {
switch (eventType) {
case 'RunStarted': return 'run'
case 'RunCompleted': return 'complete'
case 'RunFailed': return 'error'
case 'RunCancelled': return 'complete'
case 'ModelCallStarted':
case 'ModelCallCompleted':
case 'ModelCallFailed':
return 'model_call'
case 'ToolCall': return 'tool_call'
case 'ToolResult': return 'tool_result'
case 'TextDelta': return 'text'
case 'ThinkingDelta': return 'thinking'
case 'Citation': return 'citation'
case 'Usage': return 'usage'
case 'PermissionRequired':
case 'PermissionResolved':
return 'permission'
default: return 'text'
}
}
function getNodeTitle(event: AgentEvent): string {
switch (event.event) {
case 'RunStarted': return '运行开始'
case 'RunCompleted': return '运行完成'
case 'RunFailed': return '运行失败'
case 'RunCancelled': return '运行已取消'
case 'ModelCallStarted': return '模型调用'
case 'ModelCallCompleted': return '模型调用完成'
case 'ModelCallFailed': return '模型调用失败'
case 'ToolCall': return `工具调用:${event.data.name ?? '未知工具'}`
case 'ToolResult': return `工具结果:${event.data.name ?? '未知工具'}`
case 'TextDelta': return '回复文本'
case 'ThinkingDelta': return '思考中'
case 'Citation': return '引用来源'
case 'Usage': return 'Token 用量'
case 'PermissionRequired': return '需要权限确认'
case 'PermissionResolved': return '权限已处理'
default: return event.event
}
}
function getNodeSubtitle(event: AgentEvent): string | undefined {
const data = event.data
switch (event.event) {
case 'ModelCallStarted':
return [data.provider_id, data.model].filter(Boolean).join(' / ') || undefined
case 'ModelCallCompleted':
if (data.duration_ms != null) return `耗时 ${formatDuration(data.duration_ms as number)}`
return undefined
case 'ToolCall':
return `调用 ${data.name ?? 'unknown'}`
case 'ToolResult':
if (data.duration_ms != null) return `耗时 ${formatDuration(data.duration_ms as number)}`
if (data.success) return '成功'
return data.error_code ? `错误:${data.error_code}` : undefined
case 'Citation':
return data.heading_path ? String(data.heading_path) : undefined
case 'Usage': {
// total_tokens 优先;缺失时回退到 input+output 之和。
const total = asNumber(data.total_tokens)
if (total != null) return `${total} tokens`
const input = asNumber(data.input_tokens)
const output = asNumber(data.output_tokens)
if (input == null && output == null) return '- tokens'
return `${(input ?? 0) + (output ?? 0)} tokens`
}
case 'PermissionRequired':
return String(data.permission ?? '')
case 'PermissionResolved':
return String(data.decision ?? '')
default:
return undefined
}
}
function getNodeStatus(event: AgentEvent): TraceNode['status'] {
switch (event.event) {
case 'RunFailed':
case 'ModelCallFailed':
return 'error'
case 'RunCompleted':
case 'RunCancelled':
case 'ModelCallCompleted':
case 'ToolResult':
case 'Usage':
case 'PermissionResolved':
return 'completed'
case 'ToolCall':
if (event.data.status === 'completed') return 'completed'
if (event.data.status === 'error') return 'error'
return 'running'
case 'PermissionRequired':
return 'pending'
case 'ModelCallStarted':
case 'RunStarted':
case 'ThinkingDelta':
return 'running'
default:
return 'completed'
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${(ms / 60000).toFixed(1)}min`
}
/** 事件 data 是 Record<string, unknown>,取数值字段前先收窄类型。 */
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return null
}
export function calculateDuration(event1: AgentEvent, event2: AgentEvent): number {
const t1 = new Date(event1.timestamp).getTime()
const t2 = new Date(event2.timestamp).getTime()
return Math.max(0, t2 - t1)
}
export function getTotalDuration(events: AgentEvent[]): number {
if (events.length < 2) return 0
const first = events[0]
const last = events[events.length - 1]
return calculateDuration(first, last)
}
export function getToolCallsFromEvents(events: AgentEvent[]): Array<{
tool_call_id: string
name: string
status: 'pending' | 'running' | 'completed' | 'error'
arguments?: Record<string, unknown>
result?: string
duration_ms?: number
started_at?: string
completed_at?: string
}> {
const calls = new Map<string, {
tool_call_id: string
name: string
status: 'pending' | 'running' | 'completed' | 'error'
arguments?: Record<string, unknown>
result?: string
duration_ms?: number
started_at?: string
completed_at?: string
}>()
for (const event of events) {
if (event.event === 'ToolCall') {
const id = String(event.data.tool_call_id ?? '')
calls.set(id, {
tool_call_id: id,
name: String(event.data.name ?? 'unknown'),
status: 'running',
arguments: (event.data.arguments ?? event.data.parameters) as Record<string, unknown> | undefined,
started_at: event.timestamp,
})
} else if (event.event === 'ToolResult') {
const id = String(event.data.tool_call_id ?? '')
const existing = calls.get(id)
if (existing) {
existing.status = event.data.success === false ? 'error' : 'completed'
existing.result = event.data.output != null ? JSON.stringify(event.data.output) : event.data.result as string | undefined
existing.duration_ms = event.data.duration_ms as number | undefined
existing.completed_at = event.timestamp
}
}
}
return [...calls.values()]
}