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:
@@ -15,3 +15,6 @@ export * as taskService from './taskService'
|
||||
export * as indexService from './indexService'
|
||||
export * as systemService from './systemService'
|
||||
export * as workspaceService from './workspaceService'
|
||||
export * as themePackageService from './themePackageService'
|
||||
export * as mermaidService from './mermaidService'
|
||||
export * as traceService from './traceService'
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import mermaid from 'mermaid'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
let initialized = false
|
||||
let initTheme: 'light' | 'dark' = 'light'
|
||||
|
||||
function ensureInitialized(theme: 'light' | 'dark') {
|
||||
if (!initialized) {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'var(--font-ui-sans)',
|
||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
||||
sequence: { useMaxWidth: true },
|
||||
gantt: { useMaxWidth: true },
|
||||
})
|
||||
initialized = true
|
||||
initTheme = theme
|
||||
return
|
||||
}
|
||||
if (initTheme !== theme) {
|
||||
mermaid.initialize({
|
||||
theme: theme === 'dark' ? 'dark' : 'default',
|
||||
})
|
||||
initTheme = theme
|
||||
}
|
||||
}
|
||||
|
||||
export interface MermaidRenderResult {
|
||||
svg: string
|
||||
width: number
|
||||
height: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface MermaidParseError {
|
||||
message: string
|
||||
line?: number
|
||||
column?: number
|
||||
}
|
||||
|
||||
let renderCounter = 0
|
||||
|
||||
export async function renderMermaid(
|
||||
source: string,
|
||||
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' } = {}
|
||||
): Promise<MermaidRenderResult> {
|
||||
const theme = options.theme ?? 'light'
|
||||
ensureInitialized(theme)
|
||||
const id = `mermaid-${Date.now()}-${++renderCounter}`
|
||||
try {
|
||||
const result = await mermaid.render(id, source)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(result.svg, 'image/svg+xml')
|
||||
const svg = doc.querySelector('svg')
|
||||
let width = 800
|
||||
let height = 600
|
||||
if (svg) {
|
||||
const viewBox = svg.getAttribute('viewBox')
|
||||
if (viewBox) {
|
||||
const parts = viewBox.split(/\s+/).map(Number)
|
||||
if (parts.length === 4) {
|
||||
width = parts[2]
|
||||
height = parts[3]
|
||||
}
|
||||
}
|
||||
const w = svg.getAttribute('width')
|
||||
const h = svg.getAttribute('height')
|
||||
if (w && !isNaN(parseFloat(w))) width = parseFloat(w)
|
||||
if (h && !isNaN(parseFloat(h))) height = parseFloat(h)
|
||||
}
|
||||
return {
|
||||
svg: result.svg,
|
||||
width,
|
||||
height,
|
||||
warnings: [],
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Mermaid 渲染失败'
|
||||
return {
|
||||
svg: renderErrorSvg(message),
|
||||
width: 400,
|
||||
height: 120,
|
||||
warnings: [message],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderErrorSvg(message: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="120" viewBox="0 0 400 120">
|
||||
<rect width="400" height="120" fill="var(--color-error-soft, #ffebe9)" rx="6" />
|
||||
<text x="20" y="30" font-family="var(--font-ui-mono, monospace)" font-size="13" fill="var(--color-error, #cf222e)" font-weight="600">Mermaid 渲染错误</text>
|
||||
<text x="20" y="55" font-family="var(--font-ui-mono, monospace)" font-size="12" fill="var(--color-text-secondary, #656d76)">${escapeXml(message).slice(0, 100)}</text>
|
||||
<text x="20" y="90" font-family="var(--font-ui-sans, sans-serif)" font-size="11" fill="var(--color-text-tertiary, #9198a0)">请检查语法是否正确,支持 flowchart、sequenceDiagram、classDiagram 等。</text>
|
||||
</svg>`
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/[<>&'"]/g, (c) => {
|
||||
const map: Record<string, string> = { '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }
|
||||
return map[c] ?? c
|
||||
})
|
||||
}
|
||||
|
||||
export function useMermaidTheme() {
|
||||
const themeStore = useThemeStore()
|
||||
const mermaidTheme = ref<'light' | 'dark'>(themeStore.isDark ? 'dark' : 'light')
|
||||
watch(() => themeStore.isDark, (isDark) => {
|
||||
mermaidTheme.value = isDark ? 'dark' : 'light'
|
||||
ensureInitialized(mermaidTheme.value)
|
||||
})
|
||||
return { mermaidTheme }
|
||||
}
|
||||
|
||||
export async function validateMermaid(source: string): Promise<{ valid: boolean; error?: MermaidParseError }> {
|
||||
try {
|
||||
ensureInitialized('light')
|
||||
await mermaid.parse(source)
|
||||
return { valid: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误'
|
||||
return { valid: false, error: { message } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
inspectThemePackage,
|
||||
installTheme,
|
||||
listInstalledThemes,
|
||||
uninstallTheme,
|
||||
} from './themePackageService'
|
||||
import type { ThemeManifest } from '@/contracts'
|
||||
|
||||
const validYaml = `
|
||||
theme_id: ocean-blue
|
||||
name: 海洋蓝
|
||||
version: 1.2.0
|
||||
author: 测试作者
|
||||
min_app_version: 0.1.0
|
||||
css_entry: theme.css
|
||||
is_dark: false
|
||||
`
|
||||
|
||||
function manifest(overrides: Partial<ThemeManifest> = {}): ThemeManifest {
|
||||
return {
|
||||
theme_id: 'test-theme',
|
||||
name: '测试主题',
|
||||
version: '1.0.0',
|
||||
author: '作者',
|
||||
min_app_version: '0.1.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.head.querySelectorAll('style[id^="theme-style-"]').forEach((el) => el.remove())
|
||||
})
|
||||
|
||||
describe('inspectThemePackage', () => {
|
||||
it('解析合法的 manifest 并标记为兼容', async () => {
|
||||
const result = await inspectThemePackage(validYaml)
|
||||
|
||||
expect(result.compatible).toBe(true)
|
||||
expect(result.manifest.theme_id).toBe('ocean-blue')
|
||||
expect(result.manifest.name).toBe('海洋蓝')
|
||||
expect(result.manifest.version).toBe('1.2.0')
|
||||
expect(result.error_code).toBeUndefined()
|
||||
})
|
||||
|
||||
it('缺少必填字段时返回 THEME_MANIFEST_INVALID 而不是抛错', async () => {
|
||||
const result = await inspectThemePackage('theme_id: no-name\nversion: 1.0.0\n')
|
||||
|
||||
expect(result.compatible).toBe(false)
|
||||
expect(result.error_code).toBe('THEME_MANIFEST_INVALID')
|
||||
})
|
||||
|
||||
it('theme_id 含非法字符时判定不兼容', async () => {
|
||||
const result = await inspectThemePackage(validYaml.replace('ocean-blue', 'Ocean Blue!'))
|
||||
|
||||
expect(result.compatible).toBe(false)
|
||||
expect(result.error_code).toBe('THEME_MANIFEST_INVALID')
|
||||
})
|
||||
|
||||
it('css_entry 指向远程地址时判定为安全违规', async () => {
|
||||
const result = await inspectThemePackage(
|
||||
validYaml.replace('css_entry: theme.css', 'css_entry: https://evil.example.com/theme.css'),
|
||||
)
|
||||
|
||||
expect(result.compatible).toBe(false)
|
||||
expect(result.error_code).toBe('THEME_SECURITY_VIOLATION')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installTheme 的 CSS 安全校验', () => {
|
||||
it('拒绝含 @import 的 CSS', async () => {
|
||||
await expect(
|
||||
installTheme(manifest(), '@import url("https://evil.example.com/x.css");'),
|
||||
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
|
||||
})
|
||||
|
||||
it('拒绝含 javascript: 的 CSS', async () => {
|
||||
await expect(
|
||||
installTheme(manifest(), '[data-theme="test-theme"] { background: url(javascript:alert(1)); }'),
|
||||
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
|
||||
})
|
||||
|
||||
it('拒绝含 expression() 的 CSS', async () => {
|
||||
await expect(
|
||||
installTheme(manifest(), '[data-theme="test-theme"] { width: expression(alert(1)); }'),
|
||||
).rejects.toThrow(/THEME_SECURITY_VIOLATION/)
|
||||
})
|
||||
|
||||
it('不安全的 CSS 不会被注入页面', async () => {
|
||||
await installTheme(manifest(), '@import "x.css";').catch(() => {})
|
||||
|
||||
expect(document.getElementById('theme-style-test-theme')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('主题安装生命周期', () => {
|
||||
const safeCss = '[data-theme="test-theme"] { --color-accent-primary: #2f6feb; }'
|
||||
|
||||
it('安装后可列出,且默认不启用', async () => {
|
||||
const installed = await installTheme(manifest(), safeCss)
|
||||
|
||||
expect(installed.builtin).toBe(false)
|
||||
expect(installed.enabled).toBe(false)
|
||||
|
||||
const themes = await listInstalledThemes()
|
||||
expect(themes.map((t) => t.theme_id)).toContain('test-theme')
|
||||
})
|
||||
|
||||
it('安装会把 CSS 注入独立的 style 节点', async () => {
|
||||
await installTheme(manifest(), safeCss)
|
||||
|
||||
const styleEl = document.getElementById('theme-style-test-theme')
|
||||
expect(styleEl?.textContent).toContain('--color-accent-primary')
|
||||
})
|
||||
|
||||
it('重复安装同一 theme_id 只保留一份', async () => {
|
||||
await installTheme(manifest(), safeCss)
|
||||
await installTheme(manifest({ version: '2.0.0' }), safeCss)
|
||||
|
||||
const themes = await listInstalledThemes()
|
||||
expect(themes.filter((t) => t.theme_id === 'test-theme')).toHaveLength(1)
|
||||
expect(themes.find((t) => t.theme_id === 'test-theme')?.version).toBe('2.0.0')
|
||||
})
|
||||
|
||||
it('卸载会同时移除记录与注入的样式', async () => {
|
||||
await installTheme(manifest(), safeCss)
|
||||
await uninstallTheme('test-theme')
|
||||
|
||||
const themes = await listInstalledThemes()
|
||||
expect(themes.map((t) => t.theme_id)).not.toContain('test-theme')
|
||||
expect(document.getElementById('theme-style-test-theme')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,400 @@
|
||||
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
|
||||
|
||||
const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
|
||||
function loadStoredThemes(): InstalledTheme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as InstalledTheme[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveThemes(themes: InstalledTheme[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(themes))
|
||||
}
|
||||
|
||||
function validateManifest(raw: Record<string, unknown>): { manifest: ThemeManifest; warnings: string[] } {
|
||||
const warnings: string[] = []
|
||||
const required = ['theme_id', 'name', 'version', 'author', 'min_app_version', 'css_entry']
|
||||
for (const field of required) {
|
||||
if (!raw[field]) {
|
||||
throw new Error(`THEME_MANIFEST_INVALID: missing required field '${field}'`)
|
||||
}
|
||||
}
|
||||
if (!/^[a-z0-9_-]+$/.test(String(raw.theme_id))) {
|
||||
throw new Error('THEME_MANIFEST_INVALID: theme_id must match [a-z0-9_-]+')
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+/.test(String(raw.version))) {
|
||||
warnings.push('版本号格式建议使用 semver(如 1.0.0)')
|
||||
}
|
||||
const cssEntry = String(raw.css_entry)
|
||||
if (cssEntry.includes('://') || cssEntry.startsWith('data:')) {
|
||||
throw new Error('THEME_SECURITY_VIOLATION: css_entry must be a relative path within the package')
|
||||
}
|
||||
const manifest: ThemeManifest = {
|
||||
theme_id: String(raw.theme_id),
|
||||
name: String(raw.name),
|
||||
version: String(raw.version),
|
||||
author: String(raw.author),
|
||||
description: raw.description ? String(raw.description) : undefined,
|
||||
min_app_version: String(raw.min_app_version),
|
||||
is_dark: Boolean(raw.is_dark ?? false),
|
||||
css_entry: cssEntry,
|
||||
preview: raw.preview ? String(raw.preview) : undefined,
|
||||
tags: Array.isArray(raw.tags) ? raw.tags.map(String) : undefined,
|
||||
homepage: raw.homepage ? String(raw.homepage) : undefined,
|
||||
license: raw.license ? String(raw.license) : undefined,
|
||||
}
|
||||
return { manifest, warnings }
|
||||
}
|
||||
|
||||
function validateCssSafety(css: string): string[] {
|
||||
const warnings: string[] = []
|
||||
const lower = css.toLowerCase()
|
||||
if (lower.includes('@import')) {
|
||||
throw new Error('THEME_SECURITY_VIOLATION: @import is not allowed in theme CSS')
|
||||
}
|
||||
if (lower.includes('url(') && !lower.includes('url(data:')) {
|
||||
warnings.push('CSS 包含远程资源引用,预览时可能无法加载')
|
||||
}
|
||||
if (lower.includes('expression(') || lower.includes('javascript:')) {
|
||||
throw new Error('THEME_SECURITY_VIOLATION: CSS expressions are not allowed')
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
function buildCssVarsFromManifest(manifest: ThemeManifest, rawValues: Record<string, string>): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`[data-theme="${manifest.theme_id}"] {`)
|
||||
for (const [key, value] of Object.entries(rawValues)) {
|
||||
if (key.startsWith('--')) {
|
||||
lines.push(` ${key}: ${value};`)
|
||||
}
|
||||
}
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function applyThemeCss(themeId: string, css: string) {
|
||||
let styleEl = document.getElementById(`theme-style-${themeId}`) as HTMLStyleElement | null
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style')
|
||||
styleEl.id = `theme-style-${themeId}`
|
||||
document.head.appendChild(styleEl)
|
||||
}
|
||||
styleEl.textContent = css
|
||||
}
|
||||
|
||||
function removeThemeCss(themeId: string) {
|
||||
const styleEl = document.getElementById(`theme-style-${themeId}`)
|
||||
if (styleEl) styleEl.remove()
|
||||
}
|
||||
|
||||
function inspectYamlContent(yamlText: string): ThemeManifest {
|
||||
const lines = yamlText.split('\n')
|
||||
const result: Record<string, unknown> = {}
|
||||
let currentKey: string | null = null
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const match = trimmed.match(/^([a-z_]+):\s*(.*)$/i)
|
||||
if (match) {
|
||||
currentKey = match[1]
|
||||
let value = match[2].trim()
|
||||
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
||||
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1)
|
||||
else if (value === 'true') result[currentKey] = true
|
||||
else if (value === 'false') result[currentKey] = false
|
||||
else if (/^\d+$/.test(value)) result[currentKey] = Number(value)
|
||||
if (currentKey && !(currentKey in result)) result[currentKey] = value
|
||||
}
|
||||
}
|
||||
const { manifest } = validateManifest(result)
|
||||
return manifest
|
||||
}
|
||||
|
||||
export async function selectThemePackage(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.zip,.yaml,.yml,.css'
|
||||
input.multiple = false
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string)
|
||||
}
|
||||
reader.onerror = () => resolve(null)
|
||||
if (file.name.endsWith('.yaml') || file.name.endsWith('.yml')) {
|
||||
reader.readAsText(file)
|
||||
} else if (file.name.endsWith('.css')) {
|
||||
reader.readAsText(file)
|
||||
} else {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export async function inspectThemePackage(packageData: string): Promise<ThemePackageInspection> {
|
||||
const package_id = `theme_pkg_${Date.now()}`
|
||||
try {
|
||||
const manifest = inspectYamlContent(packageData)
|
||||
const warnings: string[] = []
|
||||
if (manifest.css_entry && manifest.css_entry.includes('theme.css')) {
|
||||
// 示意:Web Mock 假设 CSS 入口存在,真实 Host 会检查包内文件
|
||||
}
|
||||
return {
|
||||
package_id,
|
||||
manifest,
|
||||
preview_url: '',
|
||||
warnings,
|
||||
compatible: true,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '未知错误'
|
||||
const error_code = message.startsWith('THEME_') ? message.split(':')[0] : 'THEME_MANIFEST_INVALID'
|
||||
return {
|
||||
package_id,
|
||||
manifest: {} as ThemeManifest,
|
||||
preview_url: '',
|
||||
warnings: [message],
|
||||
compatible: false,
|
||||
error_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installTheme(
|
||||
manifest: ThemeManifest,
|
||||
cssContent: string,
|
||||
): Promise<InstalledTheme> {
|
||||
const warnings = validateCssSafety(cssContent)
|
||||
if (warnings.length > 0) {
|
||||
console.warn('[theme] CSS validation warnings:', warnings)
|
||||
}
|
||||
applyThemeCss(manifest.theme_id, cssContent)
|
||||
const installed: InstalledTheme = {
|
||||
theme_id: manifest.theme_id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
is_dark: manifest.is_dark,
|
||||
builtin: false,
|
||||
enabled: false,
|
||||
installed_at: new Date().toISOString(),
|
||||
manifest,
|
||||
code_theme: manifest.is_dark ? 'github-dark' : 'github-light',
|
||||
}
|
||||
const existing = loadStoredThemes()
|
||||
const idx = existing.findIndex((t) => t.theme_id === manifest.theme_id)
|
||||
if (idx >= 0) existing[idx] = installed
|
||||
else existing.push(installed)
|
||||
localStorage.setItem(`${STORAGE_KEY}-css-${manifest.theme_id}`, cssContent)
|
||||
saveThemes(existing)
|
||||
return installed
|
||||
}
|
||||
|
||||
export async function listInstalledThemes(): Promise<InstalledTheme[]> {
|
||||
const themes = loadStoredThemes()
|
||||
for (const theme of themes) {
|
||||
if (!theme.builtin) {
|
||||
const css = localStorage.getItem(`${STORAGE_KEY}-css-${theme.theme_id}`)
|
||||
if (css) applyThemeCss(theme.theme_id, css)
|
||||
}
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
export async function enableTheme(themeId: string): Promise<InstalledTheme> {
|
||||
const themes = loadStoredThemes()
|
||||
const theme = themes.find((t) => t.theme_id === themeId)
|
||||
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
theme.enabled = true
|
||||
saveThemes(themes)
|
||||
return theme
|
||||
}
|
||||
|
||||
export async function disableTheme(themeId: string): Promise<void> {
|
||||
const themes = loadStoredThemes()
|
||||
const theme = themes.find((t) => t.theme_id === themeId)
|
||||
if (theme) {
|
||||
theme.enabled = false
|
||||
saveThemes(themes)
|
||||
}
|
||||
}
|
||||
|
||||
export async function uninstallTheme(themeId: string): Promise<void> {
|
||||
const themes = loadStoredThemes()
|
||||
const idx = themes.findIndex((t) => t.theme_id === themeId)
|
||||
if (idx >= 0) {
|
||||
themes.splice(idx, 1)
|
||||
saveThemes(themes)
|
||||
}
|
||||
removeThemeCss(themeId)
|
||||
localStorage.removeItem(`${STORAGE_KEY}-css-${themeId}`)
|
||||
const active = localStorage.getItem(ACTIVE_CUSTOM_KEY)
|
||||
if (active === themeId) localStorage.removeItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
export function getActiveCustomTheme(): string | null {
|
||||
return localStorage.getItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
export function setActiveCustomTheme(themeId: string | null) {
|
||||
if (themeId) localStorage.setItem(ACTIVE_CUSTOM_KEY, themeId)
|
||||
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
export const mockCommunityThemes: ThemeManifest[] = [
|
||||
{
|
||||
theme_id: 'ocean-blue',
|
||||
name: 'Ocean Blue',
|
||||
version: '1.2.0',
|
||||
author: 'community',
|
||||
description: '宁静的海洋蓝色主题,适合长时间阅读',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '蓝色', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'forest-green',
|
||||
name: 'Forest Green',
|
||||
version: '1.0.1',
|
||||
author: 'nature-collection',
|
||||
description: '森林绿色护眼主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '绿色', '护眼'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'midnight-purple',
|
||||
name: 'Midnight Purple',
|
||||
version: '2.0.0',
|
||||
author: 'night-owl',
|
||||
description: '深紫色暗夜主题,适合编码',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: true,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['深色', '紫色', '极客'],
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
{
|
||||
theme_id: 'solarized-light',
|
||||
name: 'Solarized Light',
|
||||
version: '1.1.0',
|
||||
author: 'solarized',
|
||||
description: '经典 Solarized 浅色主题',
|
||||
min_app_version: '0.1.0',
|
||||
is_dark: false,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['浅色', '经典', '阅读'],
|
||||
license: 'MIT',
|
||||
},
|
||||
{
|
||||
theme_id: 'dracula',
|
||||
name: 'Dracula',
|
||||
version: '3.0.0',
|
||||
author: 'dracula-theme',
|
||||
description: '流行的 Dracula 暗色主题',
|
||||
min_app_version: '0.2.0',
|
||||
is_dark: true,
|
||||
css_entry: 'theme.css',
|
||||
tags: ['深色', '紫色', '高对比'],
|
||||
license: 'MIT',
|
||||
},
|
||||
]
|
||||
|
||||
function buildCommunityThemeCss(themeId: string, isDark: boolean, accent: string): string {
|
||||
const palettes: Record<string, { primary: string; soft: string; hover: string }> = {
|
||||
'ocean-blue': { primary: '#0077b6', soft: '#e0f0fa', hover: '#005f92' },
|
||||
'forest-green': { primary: '#2d6a4f', soft: '#e8f5ec', hover: '#1b4332' },
|
||||
'midnight-purple': { primary: '#9d4edd', soft: '#2b1a3e', hover: '#7b2cbf' },
|
||||
'solarized-light': { primary: '#b58900', soft: '#fdf6e3', hover: '#8a6d0b' },
|
||||
'dracula': { primary: '#bd93f9', soft: '#2d2a3e', hover: '#a77bf5' },
|
||||
}
|
||||
const p = palettes[themeId] ?? palettes['ocean-blue']
|
||||
if (isDark) {
|
||||
return `[data-theme="${themeId}"] {
|
||||
--color-background-primary: #1a1b26;
|
||||
--color-background-secondary: #24283b;
|
||||
--color-background-tertiary: #2f334d;
|
||||
--color-background-hover: #2d2f45;
|
||||
--color-background-active: #3d4261;
|
||||
--color-surface-primary: #24283b;
|
||||
--color-surface-secondary: #1a1b26;
|
||||
--color-surface-elevated: #2f334d;
|
||||
--color-text-primary: #c0caf5;
|
||||
--color-text-secondary: #9aa5ce;
|
||||
--color-text-tertiary: #565f89;
|
||||
--color-text-link: ${p.primary};
|
||||
--color-accent-primary: ${p.primary};
|
||||
--color-accent-primary-hover: ${p.hover};
|
||||
--color-accent-soft: ${p.soft};
|
||||
--color-border-default: #3b3f5c;
|
||||
--color-border-subtle: #2f334d;
|
||||
--color-border-focus: ${p.primary};
|
||||
--color-success: #9ece6a;
|
||||
--color-success-soft: #1f2a1a;
|
||||
--color-warning: #e0af68;
|
||||
--color-warning-soft: #2d2418;
|
||||
--color-error: #f7768e;
|
||||
--color-error-soft: #2d1a1f;
|
||||
--color-info: #7aa2f7;
|
||||
--color-info-soft: #1a2030;
|
||||
}`
|
||||
}
|
||||
return `[data-theme="${themeId}"] {
|
||||
--color-background-primary: #ffffff;
|
||||
--color-background-secondary: #f8fafc;
|
||||
--color-background-tertiary: #eef2f7;
|
||||
--color-background-hover: #f1f5f9;
|
||||
--color-background-active: #e2e8f0;
|
||||
--color-surface-primary: #ffffff;
|
||||
--color-surface-secondary: #fafbfc;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-text-primary: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-tertiary: #94a3b8;
|
||||
--color-text-link: ${p.primary};
|
||||
--color-accent-primary: ${p.primary};
|
||||
--color-accent-primary-hover: ${p.hover};
|
||||
--color-accent-soft: ${p.soft};
|
||||
--color-border-default: #e2e8f0;
|
||||
--color-border-subtle: #f1f5f9;
|
||||
--color-border-focus: ${p.primary};
|
||||
--color-success: #10b981;
|
||||
--color-success-soft: #d1fae5;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-soft: #fef3c7;
|
||||
--color-error: #ef4444;
|
||||
--color-error-soft: #fee2e2;
|
||||
--color-info: #3b82f6;
|
||||
--color-info-soft: #dbeafe;
|
||||
}`
|
||||
}
|
||||
|
||||
export async function installCommunityTheme(themeId: string): Promise<InstalledTheme> {
|
||||
const themeManifest = mockCommunityThemes.find((t) => t.theme_id === themeId)
|
||||
if (!themeManifest) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
const css = buildCommunityThemeCss(themeId, themeManifest.is_dark, themeManifest.theme_id)
|
||||
return installTheme(themeManifest, css)
|
||||
}
|
||||
|
||||
export function getCommunityThemePreviewCss(themeId: string): string {
|
||||
const t = mockCommunityThemes.find((m) => m.theme_id === themeId)
|
||||
if (!t) return ''
|
||||
return buildCommunityThemeCss(themeId, t.is_dark, themeId)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTraceNodes, getToolCallsFromEvents, getTotalDuration } from './traceService'
|
||||
import type { AgentEvent, AgentEventType } from '@/contracts'
|
||||
|
||||
let sequence = 0
|
||||
|
||||
function event(
|
||||
type: AgentEventType,
|
||||
data: Record<string, unknown> = {},
|
||||
timestamp = '2026-01-01T00:00:00.000Z',
|
||||
): AgentEvent {
|
||||
return { event: type, sequence: ++sequence, run_id: 'run-1', data, timestamp }
|
||||
}
|
||||
|
||||
describe('buildTraceNodes', () => {
|
||||
it('把模型调用期间的事件挂到该模型调用之下', () => {
|
||||
const nodes = buildTraceNodes([
|
||||
event('RunStarted'),
|
||||
event('ModelCallStarted', { model: 'mock-1' }),
|
||||
event('ToolCall', { name: 'read_note' }),
|
||||
event('ToolResult', { success: true }),
|
||||
event('ModelCallCompleted', { duration_ms: 1200 }),
|
||||
event('RunCompleted'),
|
||||
])
|
||||
|
||||
// 顶层只剩:运行开始、模型调用、运行完成
|
||||
expect(nodes).toHaveLength(3)
|
||||
const modelCall = nodes[1]
|
||||
expect(modelCall.type).toBe('model_call')
|
||||
expect(modelCall.status).toBe('completed')
|
||||
expect(modelCall.duration_ms).toBe(1200)
|
||||
expect(modelCall.children.map((c) => c.type)).toEqual(['tool_call', 'tool_result'])
|
||||
})
|
||||
|
||||
it('模型调用失败时标记为 error', () => {
|
||||
const nodes = buildTraceNodes([
|
||||
event('ModelCallStarted', { model: 'mock-1' }),
|
||||
event('ModelCallFailed', { error_code: 'PROVIDER_TIMEOUT' }),
|
||||
])
|
||||
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0].status).toBe('error')
|
||||
})
|
||||
|
||||
it('运行级事件始终留在顶层,不会被模型调用吞掉', () => {
|
||||
const nodes = buildTraceNodes([
|
||||
event('ModelCallStarted', { model: 'mock-1' }),
|
||||
event('RunFailed', { error_code: 'RUN_TIMEOUT' }),
|
||||
])
|
||||
|
||||
expect(nodes.map((n) => n.type)).toEqual(['model_call', 'error'])
|
||||
})
|
||||
|
||||
it('模型调用之外的事件保持在顶层', () => {
|
||||
const nodes = buildTraceNodes([
|
||||
event('RunStarted'),
|
||||
event('ToolCall', { name: 'search' }),
|
||||
event('RunCompleted'),
|
||||
])
|
||||
|
||||
expect(nodes).toHaveLength(3)
|
||||
expect(nodes.every((n) => n.children.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('空事件列表返回空树', () => {
|
||||
expect(buildTraceNodes([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToolCallsFromEvents', () => {
|
||||
it('按 tool_call_id 配对 ToolCall 与 ToolResult', () => {
|
||||
const calls = getToolCallsFromEvents([
|
||||
event('ToolCall', { tool_call_id: 'c1', name: 'read_note' }),
|
||||
event('ToolResult', { tool_call_id: 'c1', success: true, duration_ms: 40 }),
|
||||
])
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].name).toBe('read_note')
|
||||
expect(calls[0].status).toBe('completed')
|
||||
expect(calls[0].duration_ms).toBe(40)
|
||||
})
|
||||
|
||||
it('工具失败时状态为 error', () => {
|
||||
const calls = getToolCallsFromEvents([
|
||||
event('ToolCall', { tool_call_id: 'c2', name: 'write_note' }),
|
||||
event('ToolResult', { tool_call_id: 'c2', success: false, error_code: 'TOOL_DENIED' }),
|
||||
])
|
||||
|
||||
expect(calls[0].status).toBe('error')
|
||||
})
|
||||
|
||||
it('尚未返回结果的工具调用保持 running', () => {
|
||||
const calls = getToolCallsFromEvents([
|
||||
event('ToolCall', { tool_call_id: 'c9', name: 'write_note' }),
|
||||
])
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTotalDuration', () => {
|
||||
it('返回首尾事件的时间差', () => {
|
||||
const duration = getTotalDuration([
|
||||
event('RunStarted', {}, '2026-01-01T00:00:00.000Z'),
|
||||
event('RunCompleted', {}, '2026-01-01T00:00:02.500Z'),
|
||||
])
|
||||
|
||||
expect(duration).toBe(2500)
|
||||
})
|
||||
|
||||
it('单个事件或空列表时为 0', () => {
|
||||
expect(getTotalDuration([])).toBe(0)
|
||||
expect(getTotalDuration([event('RunStarted')])).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -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