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
@@ -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)
}