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