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
@@ -29,7 +29,9 @@ const builtinCommands = computed<Command[]>(() => [
{ id: 'search', label: '全局搜索', hint: '导航', run: () => router.push('/search') },
{ id: 'chat', label: '打开 AI 对话', hint: '导航', run: () => router.push('/chat') },
{ id: 'agent', label: '创建智能体运行', hint: '导航', run: () => router.push('/agent/runs') },
{ id: 'themes', label: '主题管理', hint: '导航', run: () => router.push('/themes') },
{ id: 'settings', label: '打开设置', hint: '导航', run: () => router.push('/settings') },
{ id: 'tasks', label: '任务列表', hint: '导航', run: () => router.push('/tasks') },
{ id: 'mode', label: `切换为${editorStore.mode === 'source' ? '写作' : '源码'}模式`, hint: '编辑器', run: () => editorStore.toggleMode() },
{ id: 'save', label: '保存当前笔记', hint: '编辑器', run: () => editorStore.save() },
{ id: 'theme', label: `切换为${themeStore.isDark ? '浅色' : '深色'}主题`, hint: '外观', run: () => themeStore.toggleTheme() },
@@ -61,6 +63,7 @@ const filteredCommands = computed(() => {
return value ? commands.value.filter((command) => `${command.label} ${command.hint}`.toLocaleLowerCase().includes(value)) : commands.value
})
function show() {
selectionSnapshot.value = window.getSelection()?.toString() || null
open.value = true
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{ source: string }>()
const themeStore = useThemeStore()
const html = ref('')
let renderVersion = 0
watch(() => props.source, async (source) => {
const diagramTheme = computed<'light' | 'dark'>(() => (themeStore.isDark ? 'dark' : 'light'))
// 主题切换需要重渲染:Mermaid SVG 的配色在渲染时烘焙,无法靠 CSS 变量事后调整。
watch([() => props.source, diagramTheme], async ([source, theme]) => {
const version = ++renderVersion
const result = await renderMarkdown(source)
const result = await renderMarkdown(source, { theme })
if (version === renderVersion) html.value = result
}, { immediate: true })
</script>
@@ -48,4 +53,27 @@ watch(() => props.source, async (source) => {
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
.markdown-content .markdown-mermaid {
overflow: auto;
margin: .85em 0;
padding: 16px;
border: 1px solid var(--color-border-default);
border-radius: 6px;
background: var(--color-surface-primary);
text-align: center;
}
.markdown-content .markdown-mermaid svg {
max-width: 100%;
height: auto;
}
.markdown-content pre.mermaid-error {
padding: 12px 16px;
border: 1px solid var(--color-error);
border-radius: 6px;
background: var(--color-error-soft);
color: var(--color-error);
white-space: pre-wrap;
font-family: var(--font-ui-mono);
font-size: .875em;
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { renderMermaid, useMermaidTheme } from '@/services/mermaidService'
const props = defineProps<{
source: string
interactive?: boolean
zoomable?: boolean
}>()
const emit = defineEmits<{
(e: 'error', message: string): void
(e: 'rendered', info: { width: number; height: number }): void
}>()
const { mermaidTheme } = useMermaidTheme()
const svgHtml = ref('')
const isLoading = ref(true)
const hasError = ref(false)
const errorMessage = ref('')
const scale = ref(1)
let renderToken = 0
const canZoom = computed(() => props.zoomable ?? props.interactive ?? false)
async function doRender() {
const token = ++renderToken
isLoading.value = true
hasError.value = false
try {
const result = await renderMermaid(props.source, {
theme: mermaidTheme.value,
mode: props.interactive ? 'interactive' : 'static',
})
if (token !== renderToken) return
svgHtml.value = result.svg
if (result.warnings.length > 0) {
hasError.value = true
errorMessage.value = result.warnings.join('\n')
emit('error', result.warnings[0])
}
emit('rendered', { width: result.width, height: result.height })
} catch (err) {
if (token !== renderToken) return
hasError.value = true
errorMessage.value = err instanceof Error ? err.message : '渲染失败'
emit('error', errorMessage.value)
} finally {
if (token === renderToken) isLoading.value = false
}
}
onMounted(doRender)
watch(() => [props.source, mermaidTheme.value], () => { scale.value = 1; doRender() })
function zoomIn() { scale.value = Math.min(scale.value * 1.2, 5) }
function zoomOut() { scale.value = Math.max(scale.value / 1.2, 0.2) }
function zoomReset() { scale.value = 1 }
</script>
<template>
<div class="mermaid-block" :class="{ interactive, 'has-error': hasError }">
<div v-if="isLoading" class="mermaid-loading">
<span class="loading-spinner"></span>
<span>正在渲染 Mermaid 图表</span>
</div>
<div
v-else
class="mermaid-container"
:style="{ transform: `scale(${scale})`, transformOrigin: 'top left' }"
v-html="svgHtml"
/>
<div v-if="canZoom && !isLoading" class="mermaid-toolbar">
<button class="toolbar-btn" @click="zoomOut" title="缩小"></button>
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
<button class="toolbar-btn" @click="zoomIn" title="放大">+</button>
<button class="toolbar-btn" @click="zoomReset" title="重置"></button>
</div>
<div v-if="hasError" class="mermaid-error">
<strong>渲染失败</strong>
<pre>{{ errorMessage }}</pre>
</div>
</div>
</template>
<style scoped>
.mermaid-block {
position: relative;
margin: .85em 0;
padding: var(--space-md);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
overflow: auto;
user-select: text;
}
.mermaid-block :deep(svg) {
max-width: 100%;
height: auto;
display: block;
}
.mermaid-container {
transition: transform var(--motion-fast);
}
.mermaid-loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-2xl);
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border-default);
border-top-color: var(--color-accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.mermaid-toolbar {
position: sticky;
bottom: 4px;
left: 0;
right: 0;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 4px 8px;
margin-top: var(--space-sm);
border-radius: var(--radius-md);
background: var(--color-background-secondary);
border: 1px solid var(--color-border-default);
}
.toolbar-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
font-size: 14px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--motion-fast);
}
.toolbar-btn:hover {
border-color: var(--color-accent-secondary);
color: var(--color-accent-primary);
}
.zoom-level {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
min-width: 44px;
text-align: center;
font-family: var(--font-ui-mono);
}
.mermaid-error {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
background: var(--color-error-soft);
color: var(--color-error);
font-size: var(--font-size-sm);
}
.mermaid-error strong { display: block; margin-bottom: 4px; }
.mermaid-error pre {
margin: 0;
white-space: pre-wrap;
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.has-error .mermaid-container {
opacity: 0.6;
}
</style>