实现第二阶段分工表中吉海燕负责的 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 通过。
195 lines
5.0 KiB
Vue
195 lines
5.0 KiB
Vue
<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>
|