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:
@@ -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()]
|
||||
}
|
||||
Reference in New Issue
Block a user