Compare commits

...
Author SHA1 Message Date
admin 6d0c1400ce fix(frontend): 保留密钥编辑并隔离社区主题预览 2026-09-05 17:46:12 +08:00
admin 0f08cd051b fix(frontend): 同步 main 并修复 phase2 关闭审阅意见 2026-09-05 17:32:34 +08:00
Kronecker 352557d94a Merge pull request 'fix(editor): 接入完整 Shiki 语言支持与 GitHub 双主题,修复语言菜单并添加图标' (#25) from fix/editor-shiki-language-picker into main
Reviewed-on: #25
2026-09-05 17:19:14 +08:00
admin 08fd62e7c5 fix(editor): 支持完整 Shiki 语言并修复语言标识与图标展示 2026-09-05 17:16:09 +08:00
admin 41bf2c53d4 fix(editor): 保留完整代码语言列表与兼容高亮 2026-09-05 16:58:39 +08:00
admin ed37099ba1 fix(editor): 修复语言菜单裁剪并接入 GitHub Shiki 配色 2026-09-05 16:52:18 +08:00
Kronecker 1c7b5b4e84 Merge pull request 'Feat(frontend)完善前端中英文支持与表单样式,持久化聊天记录并修复会话并发问题' (#24) from feat/frontend-i18n-spellcheck into main
Reviewed-on: #24
2026-09-05 15:27:01 +08:00
saint f273fef235 fix(frontend): 修复 PR #18 审阅问题并补充回归测试
审阅意见逐项修复:

1. 主题包安装丢弃用户 CSS
   inspectThemePackage 之前只解析 YAML 清单,ThemesView 安装时另外
   生成一套硬编码调色板,用户提供的 CSS 被整份丢掉。现在定义单文件
   格式(YAML 清单 + `---` + CSS),parseThemePackage 取出真实 CSS
   并原样安装;CSS 安全校验提前到预览阶段;按内容识别并拒绝 ZIP。

2. 主题恢复竞态导致页面无 data-theme
   initTheme 之前没有 await loadCustomThemes,自定义主题还没进
   allThemes,applyTheme 找不到主题直接 return。现在先同步落一个
   内置主题兜底(不写 localStorage,避免冲掉用户存的自定义主题 id),
   加载完成后再切到真正保存的那个;主题失效或列表加载失败时回退并
   通过 themeLoadWarning 告知用户,不再静默。

3. Trace 建树依赖事件相邻顺序
   后端真实顺序是 ModelCallStarted → ModelCallCompleted → Usage →
   ToolCall/ToolResult,工具在模型调用完成后才执行且并发跑,相邻性
   不可用。改为按 model_call_id / parent_model_call_id / tool_call_id
   关联;ToolResult 回填 ToolCall 的状态与耗时,结束后不再显示
   running;SSE 断点恢复的孤立事件退回顶层而不是丢弃。

4. Trace 叶子节点无法查看数据
   行的 click 是 `children.length && toggleExpand`,而详情 v-if 又
   要求 `children.length === 0`,两个条件互斥。拆成 expandedNodes
   与 detailNodes 两个状态集合;展开箭头改为独立按钮,行支持键盘
   与 aria-expanded;引用节点补「定位」按钮。同时修正 Usage 卡片
   字段(后端只发累计 token_usage)。

5. 引用定位逻辑三处重复且各自有缺陷
   抽出 navigateToCitation(依赖注入,可独立测试)+ useCitationNavigation。
   调用顺序固化:必须先 await loadFile 再 highlightBlock,否则
   editor store 的 loadFile 末尾会把高亮清掉;loadFile 失败时不跳转。
   AgentView / ChatView / AppShell 统一走这一处。

6. 插件命令 UI 重复实现
   抽出 PluginCommandPanel 复用 PluginMcpPanel 的 schema 驱动表单,
   删除 PluginsView 里的劣化副本。effect 现在真的执行 navigate /
   refresh(此前只拼成文本显示);补上必填校验与布尔字段初始值,
   修正「显示否但不提交该键」的不一致。

补充回归测试 64 项(相关 spec 由 25 项增至 89 项),并对 2、3、4 三项
缺陷做了变异验证:把修复回退成原写法后对应测试确实失败。
涉及 traceService / theme store / themePackageService / pluginCommandForm /
useCitationNavigation / TraceTimeline,其中后三个为新增文件。

vue-tsc -b、vitest(32 文件 182 项)、vite build 全部通过。
2026-09-05 10:04:17 +08:00
saint 639f38c1fc 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 通过。
2026-09-04 21:36:47 +08:00
46 changed files with 6435 additions and 789 deletions
@@ -0,0 +1,28 @@
# Frontend phase2PR #23 关闭意见修复
对应评论:https://gitea.kronecker.cc/Kronecker/NotesAgentic/pulls/23#issuecomment-97
本次在独立克隆目录整合 `feat/frontend-phase2-themes-trace-mermaid``f273fef`)与 `main``352557d`),处理关闭评论中的两项 P2 问题及合并冲突。
## 修复内容
- 主题安装和列表加载仅处理存储,不挂载 CSS。切换主题时先校验目标 CSS,再移除旧主题样式并仅挂载当前自定义主题;切回内置主题时清除自定义样式。
- 插件设置保存记录提交时的编辑版本。请求期间的新编辑保留未保存状态,可再次提交;失败保留输入并允许重试。切换插件后,旧加载和保存响应不再覆盖当前插件状态。
- 解决 9 个冲突文件,保留 main 的聊天记录持久化、会话并发修复、中英文支持及完整 Shiki 语言与图标能力,同时保留 phase2 的 Trace、引用导航、主题包、Mermaid 和共享插件命令表单。
- 内置主题列表继续随界面语言响应式更新,自定义主题列表由安装记录派生,避免维护多份可变列表。
## 验证
- 39 个前端测试文件、224 项测试通过,包含 main 与 phase2 原有用例及新增回归测试。
- 类型检查和生产构建通过;仍有大体积 chunk 提示。
- 临时恢复旧主题服务和旧插件设置面板后,5 项新增回归测试按预期失败;随后恢复修复代码。
- 独立浏览器验证页使用真实组件、主题存储及 Mermaid/Shiki 渲染;插件设置请求由页内测试接口延迟返回,不访问实际插件后端。
- 浏览器确认安装未启用主题无样式影响、多主题切换无残留、回到内置主题清除样式;保存期间继续输入后可再次保存最新值;浅色和深色下 Mermaid 与 Shiki 均生成正常内容。
未执行生产插件后端的端到端验收;本次不包含后端实现修改。
## 再次审阅后的修复
- 密钥保存使用提交快照,只清空未变化的输入;保存失败保留草稿。密钥保存、删除与普通设置保存互斥,切换插件或卸载组件后忽略旧响应。
- 未安装社区主题的预览改为独立、禁用脚本的 iframe,使用该社区主题的实际 CSS。打开和关闭预览不安装主题、不修改当前主题及持久化设置,也不保留延时回滚任务。
- 最新验证:40 个测试文件、233 项测试通过,类型检查和构建通过;浏览器确认深色社区主题在预览窗口中生效,外层仍为浅色主题,关闭后预览被移除。
+5
View File
@@ -11,8 +11,12 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "6.11.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "6.12.4",
"@codemirror/state": "6.7.1",
"@codemirror/theme-one-dark": "^6.1.0",
"@codemirror/view": "6.43.9",
"@element-plus/icons-vue": "^2.3.2",
"@milkdown/crepe": "7.22.1",
"@milkdown/kit": "7.22.1",
@@ -32,6 +36,7 @@
"codemirror": "^6.0.0",
"dompurify": "^3.4.14",
"marked": "^15.0.0",
"mermaid": "^11.17.2",
"pinia": "^4.0.0",
"shiki": "^4.4.3",
"vue": "^3.5.0",
+1023 -591
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
// Usage: node scripts/generate-language-icons.mjs /path/to/@iconify-json/vscode-icons
// Source: @iconify-json/vscode-icons 1.2.76 (MIT). No runtime network requests.
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { bundledLanguagesInfo } from 'shiki/langs'
const source = process.argv[2]
if (!source) throw new Error('Provide the extracted vscode-icons package directory')
const data = JSON.parse(readFileSync(resolve(source, 'icons.json'), 'utf8'))
const overrides = {
ahk: 'autohotkey', ahk2: 'autohotkey', asm: 'assembly', bat: 'bat',
'angular-html': 'angular', 'angular-ts': 'angular',
'common-lisp': 'lisp', 'emacs-lisp': 'lisp',
'fortran-fixed-form': 'fortran', 'fortran-free-form': 'fortran',
'git-commit': 'git', 'git-rebase': 'git',
jsonc: 'json', jsonl: 'json', shellscript: 'shell', shellsession: 'shell',
jsx: 'reactjs', tsx: 'reactts', latex: 'tex', bibtex: 'bibtex',
'objective-c': 'objectivec', 'objective-cpp': 'objectivecpp',
dart: 'dartlang', d: 'dlang', v: 'vlang', gdshader: 'godot',
fish: 'shell', 'ssh-config': 'shell', 'vue-html': 'vue', 'vue-vine': 'vue',
'html-derivative': 'html', qss: 'qt', rbs: 'ruby',
}
const groups = new Map()
const unmatched = []
for (const info of [...bundledLanguagesInfo, { id: 'text', name: 'Text' }]) {
const candidates = [overrides[info.id], info.id, ...(info.aliases ?? []), info.name.toLowerCase().replace(/\s+/g, '')].filter(Boolean)
const icon = candidates.map(name => `file-type-${name}`).find(name => data.icons[name])
if (!icon) { unmatched.push(info.id); continue }
const ids = groups.get(icon) ?? []
ids.push(info.id)
groups.set(icon, ids)
}
const base = '.milkdown-host .language-list-item[data-language]'
const svgUrl = icon => {
const item = data.icons[icon]
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${item.width ?? data.width ?? 32} ${item.height ?? data.height ?? 32}">${item.body}</svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
let css = `/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */\n${base} { display: flex; align-items: center; gap: 8px; }\n${base}::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat ${svgUrl('default-file')}; }\n`
for (const [icon, ids] of groups) {
css += ids.map(id => `${base}[data-language="${id}"]::before`).join(',\n') + ` { background-image: ${svgUrl(icon)}; }\n`
}
writeFileSync(new URL('../src/features/editor/language-icons.css', import.meta.url), css)
writeFileSync(new URL('../src/features/editor/language-icons-LICENSE.txt', import.meta.url), readFileSync(resolve(source, 'license.txt')))
console.log(`${bundledLanguagesInfo.length + 1 - unmatched.length} languages mapped; generic file icon for: ${unmatched.join(', ')}`)
+13 -4
View File
@@ -10,6 +10,7 @@ import SecondarySidebar from './SecondarySidebar.vue'
import StatusBar from './StatusBar.vue'
import TitleBar from './TitleBar.vue'
import CommandPalette from './CommandPalette.vue'
import { navigateToCitation } from '@/composables/useCitationNavigation'
defineProps<{
showSecondarySidebar?: boolean
@@ -43,10 +44,18 @@ const secondaryComponent = computed(() => {
}
})
function openCitation(noteId: string, blockId: string, filePath: string) {
workspaceStore.openFile(filePath)
editorStore.highlightBlock(blockId)
router.push('/workspace')
function openCitation(_noteId: string, blockId: string, filePath: string) {
// 走统一的定位流程:必须先 loadFile 再 highlightBlock
// 否则 editor store 的 loadFile 会把刚设好的高亮清掉。
return navigateToCitation(
{ file_path: filePath, block_id: blockId },
{
loadFile: (path) => editorStore.loadFile(path),
openFile: (path) => workspaceStore.openFile(path),
highlightBlock: (id) => editorStore.highlightBlock(id),
navigate: (path) => router.push(path),
},
)
}
defineExpose({ openCitation })
@@ -26,6 +26,8 @@ const selectionSnapshot = ref<string | null>(null)
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
const builtinCommands = computed<Command[]>(() => [
{ id: 'themes', label: t('主题管理', 'Manage themes'), hint: t('导航', 'Navigation'), run: () => router.push('/themes') },
{ id: 'tasks', label: t('任务列表', 'Tasks'), hint: t('导航', 'Navigation'), run: () => router.push('/tasks') },
{ id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') },
{ id: 'search', label: t('全局搜索', 'Global search'), hint: t('导航', 'Navigation'), run: () => router.push('/search') },
{ id: 'chat', label: t('打开 AI 对话', 'Open AI chat'), hint: t('导航', 'Navigation'), run: () => router.push('/chat') },
@@ -62,6 +64,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>
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'
import { navigateToCitation } from './useCitationNavigation'
import type { CitationNavigationDeps } from './useCitationNavigation'
function deps(overrides: Partial<CitationNavigationDeps> = {}) {
const calls: string[] = []
const base: CitationNavigationDeps = {
loadFile: vi.fn(async () => { calls.push('loadFile') }),
openFile: vi.fn(() => { calls.push('openFile') }),
highlightBlock: vi.fn(() => { calls.push('highlightBlock') }),
navigate: vi.fn(async () => { calls.push('navigate') }),
}
return { deps: { ...base, ...overrides }, calls }
}
describe('navigateToCitation', () => {
it('先加载文件再高亮,最后跳转到工作区', async () => {
// 顺序不能改:editor store 的 loadFile 末尾会把 highlightBlockId 清空
// stores/editor.ts),先 highlightBlock 会被自己冲掉。
const { deps: d, calls } = deps()
await navigateToCitation({ file_path: 'notes/a.md', block_id: 'blk-1' }, d)
expect(calls).toEqual(['loadFile', 'openFile', 'highlightBlock', 'navigate'])
expect(d.loadFile).toHaveBeenCalledWith('notes/a.md')
expect(d.highlightBlock).toHaveBeenCalledWith('blk-1')
expect(d.navigate).toHaveBeenCalledWith('/workspace')
})
it('等 loadFile 的 promise resolve 之后才高亮', async () => {
let loaded = false
const highlightBlock = vi.fn(() => {
// loadFile 还没完成就高亮,说明少了 await
expect(loaded).toBe(true)
})
const { deps: d } = deps({
loadFile: vi.fn(async () => {
await Promise.resolve()
loaded = true
}),
highlightBlock,
})
await navigateToCitation({ file_path: 'notes/a.md', block_id: 'blk-1' }, d)
expect(highlightBlock).toHaveBeenCalledTimes(1)
})
it('没有 block_id 时只打开文件,不调用高亮', async () => {
const { deps: d, calls } = deps()
await navigateToCitation({ file_path: 'notes/a.md' }, d)
expect(calls).toEqual(['loadFile', 'openFile', 'navigate'])
expect(d.highlightBlock).not.toHaveBeenCalled()
})
it('缺少 file_path 时抛出可展示的错误,且不做任何跳转', async () => {
const { deps: d } = deps()
await expect(navigateToCitation({ block_id: 'blk-1' }, d)).rejects.toThrow('该引用缺少文件路径,无法定位到笔记。')
expect(d.loadFile).not.toHaveBeenCalled()
expect(d.navigate).not.toHaveBeenCalled()
})
it('file_path 是空串或非字符串时同样拒绝', async () => {
const { deps: d } = deps()
await expect(navigateToCitation({ file_path: ' ' }, d)).rejects.toThrow(/缺少文件路径/)
await expect(navigateToCitation({ file_path: 42 }, d)).rejects.toThrow(/缺少文件路径/)
expect(d.loadFile).not.toHaveBeenCalled()
})
it('loadFile 失败时不跳转,避免把用户从未保存的编辑器里弹走', async () => {
const { deps: d } = deps({
loadFile: vi.fn(async () => { throw new Error('SAVE_CONFLICT: 当前文件有未解决的冲突') }),
})
await expect(navigateToCitation({ file_path: 'notes/a.md', block_id: 'b' }, d)).rejects.toThrow(/SAVE_CONFLICT/)
expect(d.openFile).not.toHaveBeenCalled()
expect(d.highlightBlock).not.toHaveBeenCalled()
expect(d.navigate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,64 @@
import { useRouter } from 'vue-router'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
/**
* 引用目标。字段用 unknown 是因为 Agent 事件流里拿到的是
* Record<string, unknown>SSE 原始 data),不保证结构完整。
*/
export interface CitationTarget {
file_path?: unknown
block_id?: unknown
}
export interface CitationNavigationDeps {
loadFile: (filePath: string) => Promise<void>
openFile: (filePath: string) => void
highlightBlock: (blockId: string) => void
navigate: (path: string) => Promise<unknown> | unknown
}
function asPath(value: unknown): string {
return typeof value === 'string' && value.trim() !== '' ? value : ''
}
/**
* 定位到引用对应的笔记块。
*
* 调用顺序不能改:editor store 的 loadFile 在末尾会把 highlightBlockId 清空,
* 所以必须等它 resolve 之后再 highlightBlock,否则高亮会被自己冲掉。
* loadFile 失败(例如当前文件有未解决的保存冲突)时直接抛出,
* 不跳转,避免把用户从未保存的编辑器里弹走。
*/
export async function navigateToCitation(
target: CitationTarget,
deps: CitationNavigationDeps,
): Promise<void> {
const filePath = asPath(target.file_path)
if (!filePath) throw new Error('该引用缺少文件路径,无法定位到笔记。')
await deps.loadFile(filePath)
deps.openFile(filePath)
const blockId = asPath(target.block_id)
if (blockId) deps.highlightBlock(blockId)
await deps.navigate('/workspace')
}
/** 组件里用的封装:绑定真实的 store 与路由。 */
export function useCitationNavigation() {
const router = useRouter()
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
return {
openCitation: (target: CitationTarget) =>
navigateToCitation(target, {
loadFile: (filePath) => editorStore.loadFile(filePath),
openFile: (filePath) => workspaceStore.openFile(filePath),
highlightBlock: (blockId) => editorStore.highlightBlock(blockId),
navigate: (path) => router.push(path),
}),
}
}
+106
View File
@@ -803,3 +803,109 @@ export interface ApiIndexJob {
scope: 'all' | 'notes' | 'vectors'
created_at: string
}
// ============ Theme Package (Phase 2) ============
export interface ThemeManifest {
theme_id: string
name: string
version: string
author: string
description?: string
min_app_version: string
is_dark: boolean
css_entry: string
preview?: string
tags?: string[]
homepage?: string
license?: string
}
export interface InstalledTheme {
theme_id: string
name: string
version: string
author: string
description?: string
is_dark: boolean
builtin: boolean
enabled: boolean
installed_at?: string
manifest: ThemeManifest
code_theme?: 'github-light' | 'github-dark'
}
export interface ThemePackageInspection {
package_id: string
manifest: ThemeManifest
preview_url: string
warnings: string[]
compatible: boolean
error_code?: string
/** 包内实际的主题 CSS。安装时必须用这份内容,不能另行生成。 */
css: string
}
export type ThemeErrorCode =
| 'THEME_PACKAGE_NOT_FOUND'
| 'THEME_MANIFEST_INVALID'
| 'THEME_PACKAGE_INCOMPATIBLE'
| 'THEME_PACKAGE_UNSUPPORTED_FORMAT'
| 'THEME_PACKAGE_INVALID'
| 'THEME_CSS_INVALID'
| 'THEME_SECURITY_VIOLATION'
| 'THEME_INSTALL_FAILED'
| 'THEME_UNINSTALL_FAILED'
// ============ Mermaid Renderer (Phase 2) ============
export interface MermaidRenderResult {
svg: string
width: number
height: number
warnings: string[]
}
export interface MermaidParseError {
message: string
line?: number
column?: number
}
// ============ Agent Trace Node (Phase 2 visualization) ============
export type TraceNodeType =
| 'run'
| 'model_call'
| 'tool_call'
| 'tool_result'
| 'text'
| 'thinking'
| 'citation'
| 'usage'
| 'permission'
| 'error'
| 'complete'
export interface TraceNode {
id: string
sequence: number
type: TraceNodeType
title: string
subtitle?: string
status: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'
duration_ms?: number
children: TraceNode[]
data: Record<string, unknown>
timestamp: string
parent_id?: string
}
export interface TraceTimelineGroup {
group_id: string
label: string
start_sequence: number
end_sequence: number
duration_ms?: number
nodes: TraceNode[]
}
+59 -18
View File
@@ -4,9 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import TraceTimeline from './TraceTimeline.vue'
import type { AgentEvent } from '@/contracts'
import { eventLabel, localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import { localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels'
import ToolOption from './ToolOption.vue'
import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { localeTag, t } from '@/i18n'
const route = useRoute()
@@ -14,6 +16,7 @@ const router = useRouter()
const agentStore = useAgentStore()
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const { openCitation } = useCitationNavigation()
const pageError = ref('')
const form = reactive({
input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
@@ -71,6 +74,16 @@ function eventText(event: AgentEvent) {
if (text) return String(text)
return ''
}
/** Trace 里点引用 → 打开对应笔记块。失败原因要让用户看到,不能静默。 */
async function handleOpenCitation(data: Record<string, unknown>) {
pageError.value = ''
try {
await openCitation(data)
} catch (error) {
pageError.value = error instanceof Error ? error.message : t('引用定位失败', 'Failed to open citation')
}
}
</script>
<template>
@@ -96,15 +109,33 @@ function eventText(event: AgentEvent) {
</form>
<div v-else class="trace-layout">
<div class="panel run-summary"><div><span class="badge info">{{ runStatusLabel(agentStore.activeRun?.status) }}</span><h2>{{ agentStore.activeRunId }}</h2></div><div class="inline-actions"><span>{{ t('步骤', 'Step') }} {{ agentStore.currentStep }} / {{ agentStore.activeRun?.max_steps }}</span><button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button></div></div>
<div class="timeline">
<article v-for="event in agentStore.events" :key="event.sequence" class="event-card item-card">
<div class="event-head"><span class="badge" :class="{ success: event.event === 'RunCompleted', error: event.event === 'RunFailed', warning: event.event === 'PermissionRequired' }">{{ eventLabel(event.event) }}</span><span>#{{ event.sequence }} · {{ new Date(event.timestamp).toLocaleTimeString(localeTag()) }}</span></div>
<p v-if="eventText(event)" class="event-text">{{ eventText(event) }}</p>
<pre v-if="['ToolCall', 'ToolResult', 'Citation', 'Usage'].includes(event.event)">{{ JSON.stringify(localizeDetails(event.data), null, 2) }}</pre>
</article>
<div v-if="!agentStore.events.length" class="empty-state"><div><strong>{{ t('等待执行轨迹', 'Waiting for trace events') }}</strong><p>{{ t('事件连接建立后将在这里实时显示。', 'Events will appear here after the connection is established.') }}</p></div></div>
<div class="panel run-summary">
<div>
<span class="badge" :class="{
success: agentStore.activeRun?.status === 'completed',
error: agentStore.activeRun?.status === 'failed',
warning: agentStore.activeRun?.status === 'waiting_permission',
info: agentStore.activeRun?.status === 'running' || agentStore.activeRun?.status === 'queued',
}">{{ runStatusLabel(agentStore.activeRun?.status) }}</span>
<h2>{{ agentStore.activeRun?.run_id ?? agentStore.activeRunId }}</h2>
<p v-if="agentStore.activeRun" class="run-meta">
<span>{{ t('步骤', 'Step') }} {{ agentStore.currentStep }} / {{ agentStore.activeRun.max_steps }}</span>
<span>·</span>
<span>Token: {{ agentStore.activeRun.token_usage?.total_tokens ?? 0 }}</span>
<span v-if="agentStore.activeRun.started_at">·</span>
<span v-if="agentStore.activeRun.started_at">{{ t('开始', 'Started') }}: {{ new Date(agentStore.activeRun.started_at).toLocaleString(localeTag()) }}</span>
</p>
</div>
<div class="inline-actions">
<button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button>
<button class="button-secondary" @click="agentStore.loadRun(agentStore.activeRunId!)">重新加载</button>
</div>
</div>
<TraceTimeline
:events="agentStore.events"
:run-status="agentStore.activeRun?.status"
@open-citation="handleOpenCitation"
/>
</div>
<div v-if="agentStore.permissionRequest" class="modal-backdrop">
@@ -119,14 +150,24 @@ function eventText(event: AgentEvent) {
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); }
.run-summary, .event-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.run-summary h2 { margin-top: var(--space-sm); font-family: var(--font-ui-mono); font-size: var(--font-size-lg); }
.timeline { position: relative; display: grid; gap: var(--space-md); padding-left: var(--space-md); }
.timeline::before { content: ''; position: absolute; top: 10px; bottom: 10px; left: 1px; width: 2px; border-radius: var(--radius-full); background: var(--color-border-default); }
.event-card { position: relative; }
.event-card::before { content: ''; position: absolute; top: 20px; left: calc(-1 * var(--space-md) - 5px); width: 8px; height: 8px; border: 2px solid var(--color-surface-primary); border-radius: var(--radius-full); background: var(--color-accent-primary); box-shadow: 0 0 0 1px var(--color-accent-secondary); }
.event-head { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.event-text { margin-top: var(--space-md); white-space: pre-wrap; line-height: var(--line-height-relaxed); }
pre { margin-top: var(--space-md); max-height: 260px; overflow: auto; padding: var(--space-md); border-radius: var(--radius-md); background: var(--color-background-secondary); font-family: var(--font-ui-mono); font-size: var(--font-size-xs); white-space: pre-wrap; user-select: text; }
.run-summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.run-summary h2 {
margin-top: var(--space-sm);
font-family: var(--font-ui-mono);
font-size: var(--font-size-lg);
word-break: break-all;
}
.run-meta {
display: flex;
gap: var(--space-sm);
margin-top: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.permission-actions { margin-top: var(--space-lg); }
</style>
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type { AgentEvent, AgentEventType } from '@/contracts'
import TraceTimeline from './TraceTimeline.vue'
let sequence = 0
function event(type: AgentEventType, data: Record<string, unknown> = {}): AgentEvent {
return {
event: type,
sequence: ++sequence,
run_id: 'run-1',
data,
timestamp: '2026-01-01T00:00:00.000Z',
}
}
/** 一次带工具调用的运行:模型调用有子节点,Usage / 引用是叶子。 */
function sampleEvents(): AgentEvent[] {
return [
event('RunStarted'),
event('ModelCallStarted', { model_call_id: 'mc-1', model: 'mock-1' }),
event('ModelCallCompleted', { model_call_id: 'mc-1', duration_ms: 800 }),
event('ToolCall', { tool_call_id: 'tc-1', name: 'read_note', parent_model_call_id: 'mc-1' }),
event('ToolResult', { tool_call_id: 'tc-1', name: 'read_note', success: true, parent_model_call_id: 'mc-1' }),
event('Citation', { file_path: 'notes/a.md', block_id: 'blk-1', heading_path: 'A > B' }),
event('RunCompleted'),
]
}
function mountTree(events: AgentEvent[]) {
const wrapper = mount(TraceTimeline, { props: { events } })
return wrapper
}
async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
const treeButton = wrapper.findAll('button').find((b) => b.text() === '树形')
await treeButton!.trigger('click')
return wrapper
}
describe('TraceTimeline 树形视图', () => {
it('叶子节点点击后能看到自己的数据', async () => {
// 回归:之前行的 click 是 `children.length && toggleExpand(id)`
// 而详情 v-if 又要求 children.length === 0 —— 两个条件互斥,
// 叶子节点永远打不开详情。
const wrapper = await switchToTree(mountTree(sampleEvents()))
const rows = wrapper.findAll('.node-row')
const citationRow = rows.find((row) => row.text().includes('引用来源'))
expect(citationRow).toBeTruthy()
expect(wrapper.find('.node-detail').exists()).toBe(false)
await citationRow!.trigger('click')
const detail = wrapper.find('.node-detail')
expect(detail.exists()).toBe(true)
expect(detail.text()).toContain('notes/a.md')
})
it('有子节点的节点也能查看自己的数据,不只是展开子树', async () => {
const wrapper = await switchToTree(mountTree(sampleEvents()))
const modelRow = wrapper.findAll('.node-row').find((row) => row.text().includes('模型调用'))
await modelRow!.trigger('click')
const detail = wrapper.find('.node-detail')
expect(detail.exists()).toBe(true)
expect(detail.text()).toContain('mc-1')
})
it('展开箭头只切子树,不会连带打开详情', async () => {
const wrapper = await switchToTree(mountTree(sampleEvents()))
// 初始只有顶层节点:运行开始、模型调用、引用、运行完成
expect(wrapper.findAll('.node-row')).toHaveLength(4)
const arrow = wrapper.find('.expand-icon:not(.placeholder)')
expect(arrow.exists()).toBe(true)
await arrow.trigger('click')
// 子节点出现,但没有任何详情面板被打开
expect(wrapper.findAll('.node-row')).toHaveLength(5)
expect(wrapper.text()).toContain('工具调用:read_note')
expect(wrapper.find('.node-detail').exists()).toBe(false)
})
it('键盘 Enter 与空格可以打开详情', async () => {
const wrapper = await switchToTree(mountTree(sampleEvents()))
const row = wrapper.findAll('.node-row').find((r) => r.text().includes('运行开始'))!
await row.trigger('keydown.enter')
expect(wrapper.find('.node-detail').exists()).toBe(true)
await row.trigger('keydown.space')
expect(wrapper.find('.node-detail').exists()).toBe(false)
})
it('行的 aria-expanded 跟随详情开合', async () => {
const wrapper = await switchToTree(mountTree(sampleEvents()))
const row = wrapper.findAll('.node-row').find((r) => r.text().includes('运行开始'))!
expect(row.attributes('aria-expanded')).toBe('false')
await row.trigger('click')
expect(row.attributes('aria-expanded')).toBe('true')
})
it('引用节点带「定位」按钮,点击后抛出 open-citation 且不打开详情', async () => {
const wrapper = await switchToTree(mountTree(sampleEvents()))
const locate = wrapper.find('.node-locate')
expect(locate.exists()).toBe(true)
await locate.trigger('click')
const emitted = wrapper.emitted('open-citation')
expect(emitted).toHaveLength(1)
expect((emitted![0][0] as Record<string, unknown>).file_path).toBe('notes/a.md')
// @click.stop 生效,行的详情不该被顺带打开
expect(wrapper.find('.node-detail').exists()).toBe(false)
})
it('引用缺少 file_path 时不显示定位按钮', async () => {
const wrapper = await switchToTree(mountTree([event('Citation', { heading_path: 'A' })]))
expect(wrapper.find('.node-locate').exists()).toBe(false)
})
})
describe('TraceTimeline 时间线视图', () => {
it('Usage 卡片读后端真实字段 token_usage', () => {
// 后端只发累计的 token_usageruntime.py),没有 input/output/total_tokens。
const wrapper = mountTree([event('Usage', { token_usage: 1024 })])
expect(wrapper.find('.event-usage').text()).toContain('1024')
})
it('Usage 缺字段时显示占位符而不是 undefined', () => {
const wrapper = mountTree([event('Usage', {})])
const text = wrapper.find('.event-usage').text()
expect(text).toContain('-')
expect(text).not.toContain('undefined')
})
it('点击引用卡片抛出 open-citation', async () => {
const wrapper = mountTree([event('Citation', { file_path: 'notes/a.md', heading_path: 'A' })])
await wrapper.find('.event-citation').trigger('click')
expect(wrapper.emitted('open-citation')).toHaveLength(1)
})
it('工具调用统计按 ToolResult 显示最终状态,不停在 running', () => {
const wrapper = mountTree([
event('ToolCall', { tool_call_id: 'tc-9', name: 'write_note' }),
event('ToolResult', { tool_call_id: 'tc-9', name: 'write_note', success: false, error_code: 'TOOL_DENIED' }),
])
const item = wrapper.find('.tool-call-item')
expect(item.classes()).toContain('error')
expect(item.text()).toContain('失败')
})
it('没有事件时显示等待态', () => {
const wrapper = mountTree([])
expect(wrapper.find('.empty-state').text()).toContain('等待执行轨迹')
})
})
@@ -0,0 +1,728 @@
<script setup lang="ts">
import { localeTag, t } from '@/i18n'
import { computed, ref } from 'vue'
import type { TraceNode, AgentEvent } from '@/contracts'
import { buildTraceNodes, getToolCallsFromEvents, getTotalDuration } from '@/services/traceService'
import { eventLabel, localizeDetails } from './labels'
const props = defineProps<{
events: AgentEvent[]
runStatus?: string
}>()
const emit = defineEmits<{
(e: 'open-citation', data: Record<string, unknown>): void
}>()
// 子树展开与「查看本节点数据」是两件事:
// 叶子节点没有子树,但依然需要能看自己的 data,
// 所以两个状态集合分开维护,不能共用一个 expanded。
const expandedNodes = ref<Set<string>>(new Set())
const detailNodes = ref<Set<string>>(new Set())
const viewMode = ref<'timeline' | 'tree'>('timeline')
const showDetails = ref(true)
const traceNodes = computed(() => buildTraceNodes(props.events))
const toolCalls = computed(() => getToolCallsFromEvents(props.events))
const totalDuration = computed(() => getTotalDuration(props.events))
const summaryStats = computed(() => {
const events = props.events
return {
totalEvents: events.length,
modelCalls: events.filter((e) => e.event === 'ModelCallStarted').length,
toolCalls: events.filter((e) => e.event === 'ToolCall').length,
citations: events.filter((e) => e.event === 'Citation').length,
errors: events.filter((e) => e.event.endsWith('Failed') || e.event === 'RunFailed').length,
}
})
function toggle(set: Set<string>, nodeId: string) {
if (set.has(nodeId)) {
set.delete(nodeId)
} else {
set.add(nodeId)
}
}
/** 展开/收起子树,只对有 children 的节点有意义。 */
function toggleExpand(nodeId: string) {
toggle(expandedNodes.value, nodeId)
}
function isExpanded(nodeId: string): boolean {
return expandedNodes.value.has(nodeId)
}
/** 查看/隐藏本节点自身的数据,任何节点(含叶子)都可用。 */
function toggleDetail(nodeId: string) {
toggle(detailNodes.value, nodeId)
}
function isDetailOpen(nodeId: string): boolean {
return detailNodes.value.has(nodeId)
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString(localeTag(), { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
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)}m`
}
function getNodeIcon(type: TraceNode['type']): string {
const icons: Record<TraceNode['type'], string> = {
run: '▶',
model_call: '🤖',
tool_call: '🔧',
tool_result: '✅',
text: '💬',
thinking: '🧠',
citation: '📚',
usage: '📊',
permission: '🔒',
error: '❌',
complete: '🏁',
}
return icons[type] ?? '•'
}
function getNodeStatusClass(node: TraceNode): string {
switch (node.status) {
case 'running': return 'status-running'
case 'completed': return 'status-completed'
case 'error': return 'status-error'
case 'pending': return 'status-pending'
case 'cancelled': return 'status-cancelled'
default: return 'status-completed'
}
}
/** 引用节点带 file_path 才能定位到笔记块。 */
function isCitationNode(node: TraceNode): boolean {
return node.type === 'citation' && typeof node.data.file_path === 'string'
}
function prettyData(data: Record<string, unknown>): string {
const filtered = { ...data }
if (typeof filtered.output === 'string' && filtered.output.length > 500) {
filtered.output = filtered.output.slice(0, 500) + '...'
}
return JSON.stringify(localizeDetails(filtered), null, 2)
}
function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; depth: number }> {
const result: Array<{ node: TraceNode; depth: number }> = []
for (const node of nodes) {
result.push({ node, depth })
if (node.children.length > 0 && isExpanded(node.id)) {
result.push(...flatNodes(node.children, depth + 1))
}
}
return result
}
const flatTrace = computed(() => flatNodes(traceNodes.value))
</script>
<template>
<div class="trace-visualization">
<div class="trace-header">
<div class="trace-stats">
<div class="stat-item">
<span class="stat-value">{{ summaryStats.totalEvents }}</span>
<span class="stat-label">事件</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ summaryStats.modelCalls }}</span>
<span class="stat-label">模型调用</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ summaryStats.toolCalls }}</span>
<span class="stat-label">工具调用</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ summaryStats.citations }}</span>
<span class="stat-label">引用</span>
</div>
<div class="stat-item">
<span class="stat-value duration">{{ totalDuration > 0 ? formatDuration(totalDuration) : '-' }}</span>
<span class="stat-label">总耗时</span>
</div>
</div>
<div class="trace-controls">
<div class="view-toggle">
<button :class="{ active: viewMode === 'timeline' }" @click="viewMode = 'timeline'">时间线</button>
<button :class="{ active: viewMode === 'tree' }" @click="viewMode = 'tree'">树形</button>
</div>
<button class="detail-toggle" @click="showDetails = !showDetails">
{{ showDetails ? '隐藏详情' : '显示详情' }}
</button>
</div>
</div>
<div v-if="viewMode === 'timeline'" class="timeline-view">
<div class="timeline">
<article
v-for="event in events"
:key="event.sequence"
class="event-card"
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
>
<div class="event-dot" :class="`dot-${event.event}`"></div>
<div class="event-content" @click="toggleDetail(`event-${event.sequence}`)">
<div class="event-header">
<span class="event-badge" :class="{
success: event.event === 'RunCompleted' || event.event === 'ModelCallCompleted',
error: event.event.endsWith('Failed') || event.event === 'RunFailed',
warning: event.event === 'PermissionRequired',
info: event.event === 'ToolCall' || event.event === 'ModelCallStarted',
}">{{ eventLabel(event.event as any) }}</span>
<span class="event-time">{{ formatTime(event.timestamp) }}</span>
</div>
<div v-if="event.data.text || event.data.message" class="event-text">
{{ (event.data.text || event.data.message) as string }}
</div>
<div v-else-if="event.data.name" class="event-name">
<code>{{ event.data.name as string }}</code>
<span v-if="event.data.duration_ms != null" class="event-duration">
{{ formatDuration(event.data.duration_ms as number) }}
</span>
</div>
<div v-if="event.event === 'Usage'" class="event-usage">
<span class="total">累计: {{ event.data.token_usage ?? '-' }} tokens</span>
</div>
<div v-if="event.event === 'Citation'" class="event-citation" @click.stop="emit('open-citation', event.data)">
<span class="cite-icon">📎</span>
<span>{{ (event.data.heading_path || event.data.note_title || event.data.file_path) as string }}</span>
</div>
<div v-if="event.event === 'PermissionRequired'" class="event-permission">
<span class="perm-label">权限:</span>
<code>{{ event.data.permission as string }}</code>
</div>
</div>
<div v-if="isDetailOpen(`event-${event.sequence}`) && showDetails" class="event-detail">
<details open>
<summary>完整数据</summary>
<pre>{{ prettyData(event.data) }}</pre>
</details>
</div>
</article>
<div v-if="!events.length" class="empty-state">
<div><strong>{{ t('等待执行轨迹', 'Waiting for trace events') }}</strong><p>{{ t('事件连接建立后将在这里实时显示。', 'Events will appear here after the connection is established.') }}</p></div>
</div>
</div>
</div>
<div v-else class="tree-view">
<div v-for="item in flatTrace" :key="item.node.id" class="tree-node" :style="{ paddingLeft: `${item.depth * 24 + 8}px` }">
<div
class="node-row"
:class="[getNodeStatusClass(item.node), { 'detail-open': isDetailOpen(item.node.id) }]"
role="button"
tabindex="0"
:aria-expanded="isDetailOpen(item.node.id)"
@click="toggleDetail(item.node.id)"
@keydown.enter.prevent="toggleDetail(item.node.id)"
@keydown.space.prevent="toggleDetail(item.node.id)"
>
<button
v-if="item.node.children.length"
type="button"
class="expand-icon"
:aria-label="isExpanded(item.node.id) ? '收起子调用' : `展开 ${item.node.children.length} 个子调用`"
@click.stop="toggleExpand(item.node.id)"
>
{{ isExpanded(item.node.id) ? '▼' : '▶' }}
</button>
<span v-else class="expand-icon placeholder"></span>
<span class="node-icon">{{ getNodeIcon(item.node.type) }}</span>
<span class="node-title">{{ item.node.title }}</span>
<span v-if="item.node.subtitle" class="node-subtitle">{{ item.node.subtitle }}</span>
<span v-if="item.node.duration_ms != null" class="node-duration">
{{ formatDuration(item.node.duration_ms) }}
</span>
<button
v-if="isCitationNode(item.node)"
type="button"
class="node-locate"
@click.stop="emit('open-citation', item.node.data)"
>
定位
</button>
</div>
<div v-if="isDetailOpen(item.node.id) && showDetails" class="node-detail">
<pre>{{ prettyData(item.node.data) }}</pre>
</div>
</div>
<div v-if="!traceNodes.length" class="empty-state">
<div><strong>暂无树形数据</strong><p>运行开始后将展示调用树</p></div>
</div>
</div>
<div v-if="toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
<h3 class="panel-title">工具调用统计</h3>
<div class="tool-call-list">
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status">
<span class="tool-status-dot"></span>
<code class="tool-name">{{ call.name }}</code>
<span v-if="call.duration_ms != null" class="tool-duration">
{{ formatDuration(call.duration_ms) }}
</span>
<span class="tool-status-badge" :class="call.status">
{{ call.status === 'completed' ? '成功' : call.status === 'error' ? '失败' : call.status }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.trace-visualization {
display: grid;
gap: var(--space-lg);
}
.trace-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
flex-wrap: wrap;
}
.trace-stats {
display: flex;
gap: var(--space-lg);
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-value {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.stat-value.duration {
color: var(--color-accent-primary);
}
.stat-label {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.trace-controls {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-toggle button {
padding: 4px 12px;
background: var(--color-surface-primary);
border: none;
border-right: 1px solid var(--color-border-default);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--motion-fast);
}
.view-toggle button:last-child { border-right: none; }
.view-toggle button.active {
background: var(--color-accent-primary);
color: var(--color-text-inverse);
}
.detail-toggle {
padding: 4px 12px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
}
.detail-toggle:hover { border-color: var(--color-accent-secondary); }
.timeline {
position: relative;
display: grid;
gap: var(--space-sm);
padding-left: var(--space-md);
}
.timeline::before {
content: '';
position: absolute;
top: 10px;
bottom: 10px;
left: 7px;
width: 2px;
border-radius: var(--radius-full);
background: var(--color-border-default);
}
.event-card {
position: relative;
padding: var(--space-md);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
transition: border-color var(--motion-fast), box-shadow var(--motion-fast);
}
.event-card:hover {
border-color: var(--color-accent-secondary);
box-shadow: var(--shadow-sm);
}
.event-dot {
position: absolute;
top: 18px;
left: -22px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-accent-primary);
border: 2px solid var(--color-surface-primary);
box-shadow: 0 0 0 1px var(--color-border-default);
}
.dot-RunStarted, .dot-ModelCallStarted { background: var(--color-accent-primary); }
.dot-RunCompleted, .dot-ModelCallCompleted, .dot-ToolResult { background: var(--color-success); }
.dot-RunFailed, .dot-ModelCallFailed { background: var(--color-error); }
.dot-ToolCall { background: var(--color-info); }
.dot-PermissionRequired { background: var(--color-warning); }
.dot-ThinkingDelta { background: var(--color-text-tertiary); }
.dot-TextDelta { background: var(--color-text-secondary); }
.dot-Citation { background: var(--color-accent-secondary); }
.dot-Usage { background: var(--color-text-tertiary); }
.event-content {
cursor: pointer;
}
.event-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.event-badge {
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: 500;
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
}
.event-badge.success {
background: var(--color-success-soft);
color: var(--color-success);
}
.event-badge.error {
background: var(--color-error-soft);
color: var(--color-error);
}
.event-badge.warning {
background: var(--color-warning-soft);
color: var(--color-warning);
}
.event-badge.info {
background: var(--color-info-soft);
color: var(--color-info);
}
.event-time {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-family: var(--font-ui-mono);
}
.event-text {
white-space: pre-wrap;
line-height: var(--line-height-relaxed);
color: var(--color-text-primary);
max-height: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.event-name {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.event-name code {
padding: 2px 6px;
background: var(--color-background-secondary);
border-radius: var(--radius-sm);
font-family: var(--font-ui-mono);
font-size: var(--font-size-sm);
}
.event-duration {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-family: var(--font-ui-mono);
}
.event-usage {
display: flex;
gap: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-family: var(--font-ui-mono);
}
.event-usage .total {
color: var(--color-accent-primary);
font-weight: 500;
}
.event-citation {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--color-accent-soft);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-accent-primary);
cursor: pointer;
}
.event-citation:hover { text-decoration: underline; }
.event-permission {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--font-size-sm);
}
.event-permission code {
padding: 2px 6px;
background: var(--color-warning-soft);
color: var(--color-warning);
border-radius: var(--radius-sm);
font-family: var(--font-ui-mono);
}
.event-detail {
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border-subtle);
}
.event-detail details summary {
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.event-detail pre {
margin-top: var(--space-sm);
max-height: 300px;
overflow: auto;
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-background-secondary);
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
white-space: pre-wrap;
word-break: break-all;
}
.tree-view {
padding: var(--space-sm) 0;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.tree-node {
border-bottom: 1px solid var(--color-border-subtle);
}
.tree-node:last-child { border-bottom: none; }
.node-row {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 12px;
cursor: pointer;
font-size: var(--font-size-sm);
transition: background-color var(--motion-fast);
}
.node-row:hover { background: var(--color-background-hover); }
.node-row:focus-visible {
outline: 2px solid var(--color-accent-primary);
outline-offset: -2px;
}
.node-row.detail-open { background: var(--color-background-secondary); }
.node-row.status-running {
background: var(--color-info-soft);
}
.node-row.status-error {
background: var(--color-error-soft);
}
.expand-icon {
width: 16px;
padding: 0;
background: none;
border: none;
font-size: 10px;
color: var(--color-text-tertiary);
cursor: pointer;
flex-shrink: 0;
}
.expand-icon.placeholder { visibility: hidden; }
.node-locate {
padding: 1px 8px;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-accent-primary);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
}
.node-locate:hover { border-color: var(--color-accent-primary); }
.node-icon {
font-size: 14px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.node-title {
flex: 1;
color: var(--color-text-primary);
font-weight: 500;
}
.node-subtitle {
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.node-duration {
color: var(--color-text-tertiary);
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
}
.node-detail {
padding: 8px 12px 12px 36px;
}
.node-detail pre {
margin: 0;
max-height: 200px;
overflow: auto;
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-background-secondary);
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
white-space: pre-wrap;
}
.tool-calls-summary { margin-top: var(--space-md); }
.tool-call-list {
display: grid;
gap: var(--space-xs);
}
.tool-call-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 6px 10px;
border-radius: var(--radius-sm);
background: var(--color-background-secondary);
font-size: var(--font-size-sm);
}
.tool-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-tertiary);
}
.tool-call-item.completed .tool-status-dot { background: var(--color-success); }
.tool-call-item.error .tool-status-dot { background: var(--color-error); }
.tool-call-item.running .tool-status-dot { background: var(--color-info); }
.tool-name {
flex: 1;
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
}
.tool-duration {
color: var(--color-text-tertiary);
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
}
.tool-status-badge {
padding: 1px 6px;
border-radius: var(--radius-full);
font-size: 11px;
}
.tool-status-badge.completed { background: var(--color-success-soft); color: var(--color-success); }
.tool-status-badge.error { background: var(--color-error-soft); color: var(--color-error); }
.tool-status-badge.running { background: var(--color-info-soft); color: var(--color-info); }
.empty-state {
padding: var(--space-3xl);
text-align: center;
color: var(--color-text-tertiary);
}
.empty-state strong {
display: block;
color: var(--color-text-secondary);
margin-bottom: var(--space-xs);
}
</style>
+10 -12
View File
@@ -1,21 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Citation } from '@/contracts'
import { useChatStore } from '@/stores/chat'
import { useEditorStore } from '@/stores/editor'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import { useWorkspaceStore } from '@/stores/workspace'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { t } from '@/i18n'
const chatStore = useChatStore()
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const workspaceStore = useWorkspaceStore()
const editorStore = useEditorStore()
const router = useRouter()
const { openCitation } = useCitationNavigation()
const loadError = ref('')
let disposed = false
onBeforeUnmount(() => { disposed = true })
@@ -52,11 +48,13 @@ watch(() => chatStore.selectedProviderId, async (providerId) => {
function send() { void chatStore.sendMessage(chatStore.inputText) }
async function openCitation(citation: Citation) {
await editorStore.loadFile(citation.file_path)
workspaceStore.openFile(citation.file_path)
editorStore.highlightBlock(citation.block_id)
await router.push('/workspace')
async function openCitationCard(citation: Citation) {
loadError.value = ''
try {
await openCitation(citation)
} catch (error) {
loadError.value = error instanceof Error ? error.message : t('引用定位失败', 'Failed to open citation')
}
}
</script>
@@ -81,7 +79,7 @@ async function openCitation(citation: Citation) {
<div v-else-if="chatStore.isStreaming" class="message-content">{{ t('正在思考', 'Thinking') }}</div>
<div v-if="message.tool_calls?.length" class="tool-calls"><div v-for="call in message.tool_calls" :key="call.tool_call_id" class="item-card"><span class="badge info">{{ call.status }}</span><strong>{{ call.name }}</strong><pre>{{ JSON.stringify(call.parameters, null, 2) }}</pre></div></div>
<div v-if="message.citations?.length" class="citations">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitation(citation)">
<button v-for="(citation, index) in message.citations" :key="citation.block_id" class="citation-card" @click="openCitationCard(citation)">
<span class="badge info">{{ index + 1 }}</span><span><strong>{{ citation.heading_path || citation.file_path }}</strong><small>{{ citation.content }}</small></span>
</button>
</div>
@@ -3,10 +3,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { NodeSelection, TextSelection } from '@milkdown/kit/prose/state'
import { getMarkdown } from '@milkdown/kit/utils'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -45,6 +49,51 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
editor.action(ctx => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, 0)))
})
for (let attempt = 0; attempt < 100 && !wrapper.find('.language-button').exists(); attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await wrapper.get('.language-button').trigger('click')
const item = wrapper.get(`.language-list-item[data-language="${id}"]`)
expect(item.text()).toBe(label)
await item.trigger('click')
const markdown = editor.action(getMarkdown())
expect(markdown).toContain(`\`\`\`${id}\n`)
const html = await renderMarkdown(markdown)
expect(new Set([...html.matchAll(/--shiki-light:([^;" ]+)/g)].map(match => match[1])).size).toBeGreaterThan(1)
const reopened = mount(VisualMarkdownEditor, { props: { initialContent: markdown }, attachTo: document.body })
mounted.push(reopened)
const restored = await waitForEditor(reopened)
expect(restored.action(ctx => ctx.get(editorViewCtx).state.doc.firstChild?.attrs.language)).toBe(id)
})
it.each(['github-light', 'github-dark'] as const)('keeps Shiki %s mappings after Crepe merges its defaults', async theme => {
useThemeStore().codeBlockTheme = theme
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```python\nprint("Hello")\n```' }, attachTo: document.body })
mounted.push(wrapper)
const editor = await waitForEditor(wrapper)
const config = editor.action(ctx => ctx.get(codeBlockConfig.key))
const matching = config.languages.filter(item => item.alias.includes('python'))
expect(matching).toHaveLength(1)
for (const name of ['Java', 'Go', 'Rust']) {
const language = config.languages.find(item => item.name === name.toLowerCase())
expect(language, `${name} remains available`).toBeDefined()
const view = new CodeMirror({ doc: 'class Example {}', extensions: [...config.extensions, await language!.load()] })
try { expect(view.dom.querySelector('.shiki-token')).not.toBeNull() }
finally { view.destroy() }
}
const cm = new CodeMirror({ doc: 'print("Hello")', extensions: [...config.extensions, await matching[0]!.load()] })
try {
const string = [...cm.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(el => el.textContent?.includes('Hello'))
expect(string?.style.color.toUpperCase()).toBe(theme === 'github-dark' ? '#9ECBFF' : '#032F62')
} finally { cm.destroy() }
})
it('applies bold from the top toolbar to the selected text', async () => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'alpha beta' }, attachTo: document.body })
mounted.push(wrapper)
@@ -2,7 +2,13 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe'
import { oneDark } from '@codemirror/theme-one-dark'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { basicSetup } from 'codemirror'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { shikiEditorTheme, shikiLanguages, renderCodeLanguage } from './shikiCodeMirror'
import './language-icons.css'
import { installLanguagePickerPopover } from './languagePickerPopover'
import {
createCodeBlockCommand,
toggleEmphasisCommand,
@@ -34,6 +40,7 @@ const editorRoot = ref<HTMLElement | null>(null)
const loading = ref(true)
const fontSizeInput = ref(16)
let crepe: Crepe | null = null
let disposeLanguagePicker: (() => void) | undefined
function applyProofingPreferences() {
const editable = editorRoot.value?.querySelector<HTMLElement>('.ProseMirror')
@@ -118,7 +125,6 @@ onMounted(async () => {
featureConfigs: {
[Crepe.Feature.Placeholder]: { text: t('开始记录你的想法…', 'Start writing your thoughts…') },
[Crepe.Feature.CodeMirror]: {
theme: themeStore.resolvedCodeBlockTheme === 'github-dark' ? oneDark : [],
previewOnlyByDefault: false,
searchPlaceholder: t('搜索语言', 'Search languages'),
noResultText: t('没有匹配的语言', 'No matching language'),
@@ -170,6 +176,14 @@ onMounted(async () => {
},
},
})
// Crepe's defaultsDeep merges language arrays and theme extension internals.
// Replace both AFTER feature configuration to avoid default grammar collisions.
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
...config,
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
renderLanguage: renderCodeLanguage,
extensions: [basicSetup, keymap.of([indentWithTab]), shikiEditorTheme(themeStore.resolvedCodeBlockTheme)],
})))
crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
@@ -180,13 +194,14 @@ onMounted(async () => {
})
})
await crepe.create()
if (editorRoot.value) disposeLanguagePicker = installLanguagePickerPopover(editorRoot.value)
applyProofingPreferences()
loading.value = false
})
watch([() => settingsStore.spellCheck, () => settingsStore.language], applyProofingPreferences)
onBeforeUnmount(() => { void crepe?.destroy() })
onBeforeUnmount(() => { disposeLanguagePicker?.(); void crepe?.destroy() })
defineExpose({ getEditor: () => crepe?.editor })
</script>
@@ -288,7 +303,10 @@ defineExpose({ getEditor: () => crepe?.editor })
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
.milkdown-host :deep(.font-size-marker) { display: none; }
.milkdown-host :deep(.milkdown-code-block) { overflow: hidden; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.milkdown-host :deep(.language-picker[popover]) { position: fixed !important; inset: auto; left: var(--picker-left) !important; top: var(--picker-top) !important; margin: 0; padding: 0; border: 0; overflow: visible; background: transparent; color: var(--color-text-primary); }
.milkdown-host :deep(.language-picker .language-list) { height: auto; max-height: var(--picker-list-height, 280px); }
.milkdown-host :deep(.language-picker .list-wrapper) { width: min(260px, calc(100vw - 24px)); border: 1px solid var(--color-border-default); background: var(--color-surface-elevated); box-shadow: var(--shadow-md); }
.milkdown-host :deep(.milkdown-code-block .cm-editor),
.milkdown-host :deep(.milkdown-code-block .cm-gutters),
.milkdown-host :deep(.milkdown-code-block .cm-panel) { background: var(--color-code-background); }
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Roberto Huertas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
/** Promote Milkdown's menu to the top layer without moving its Vue-owned DOM. */
export function installLanguagePickerPopover(root: HTMLElement): () => void {
const menus = new Set<HTMLElement>()
function sync() {
root.querySelectorAll<HTMLElement>('.language-picker').forEach(menu => {
const trigger = menu.parentElement?.querySelector<HTMLElement>('.language-button')
if (!trigger || typeof menu.showPopover !== 'function') return
menus.add(menu)
menu.setAttribute('popover', 'manual')
const search = menu.querySelector<HTMLInputElement>('.search-input')
if (search) {
search.autocomplete = 'off'
search.spellcheck = false
}
if (trigger.dataset.expanded !== 'true' || !menu.firstElementChild) {
if (menu.matches(':popover-open')) menu.hidePopover()
return
}
if (!menu.matches(':popover-open')) menu.showPopover()
const anchor = trigger.getBoundingClientRect()
const below = window.innerHeight - anchor.bottom - 16
const above = anchor.top - 16
const placeAbove = below < 240 && above > below
const available = Math.max(80, placeAbove ? above : below)
menu.style.setProperty('--picker-list-height', `${Math.min(280, Math.max(32, available - 64))}px`)
const bounds = menu.getBoundingClientRect()
menu.style.setProperty('--picker-left', `${Math.max(12, Math.min(anchor.left, window.innerWidth - bounds.width - 12))}px`)
menu.style.setProperty('--picker-top', `${Math.max(12, placeAbove ? anchor.top - bounds.height - 8 : anchor.bottom + 8)}px`)
})
for (const menu of menus) if (!root.contains(menu)) menus.delete(menu)
}
const observer = new MutationObserver(sync)
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
root.addEventListener('scroll', sync, true)
window.addEventListener('resize', sync)
sync()
return () => {
observer.disconnect()
root.removeEventListener('scroll', sync, true)
window.removeEventListener('resize', sync)
for (const menu of menus) if (menu.matches(':popover-open')) menu.hidePopover()
}
}
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, expect, it } from 'vitest'
import { Compartment } from '@codemirror/state'
import { bundledLanguagesInfo } from 'shiki/langs'
import { EditorView } from '@codemirror/view'
import { shikiLanguage, shikiLanguages } from './shikiCodeMirror'
import { getCodeTokenizer } from '@/utils/markdown'
const editors: EditorView[] = []
afterEach(() => { editors.splice(0).forEach(view => view.destroy()) })
it.each(['github-light', 'github-dark'] as const)('uses Shiki %s tokens and updates editable content', async theme => {
const support = await shikiLanguage('python', theme)
const view = new EditorView({ doc: 'print("Hello")', extensions: [support] })
editors.push(view)
const tokenize = await getCodeTokenizer(theme)
const expected = tokenize('print("Hello")', 'python')[0]!.find(token => token.content.includes('Hello'))!
const colored = [...view.dom.querySelectorAll<HTMLElement>('.shiki-token')].find(node => node.textContent?.includes('Hello'))!
expect(colored).toBeDefined()
const sample = document.createElement('span'); sample.style.color = expected.color!
expect(colored.style.color).toBe(sample.style.color)
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'def hello():\n return 42' } })
expect(view.state.doc.toString()).toContain('return 42')
expect(view.dom.querySelectorAll('.shiki-token').length).toBeGreaterThan(2)
expect(view.dom.textContent).toContain('return 42')
})
it('reconfigures language and theme without modifying the document', async () => {
const config = new Compartment()
const view = new EditorView({ doc: 'const answer = 42', extensions: [config.of(await shikiLanguage('javascript', 'github-light'))] })
editors.push(view)
const before = view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color
view.dispatch({ effects: config.reconfigure(await shikiLanguage('javascript', 'github-dark')) })
expect(view.dom.querySelector<HTMLElement>('.shiki-token')!.style.color).not.toBe(before)
expect(view.state.doc.toString()).toBe('const answer = 42')
view.dispatch({ effects: config.reconfigure(await shikiLanguage('text', 'github-dark')) })
expect(view.state.doc.toString()).toBe('const answer = 42')
})
it('offers fenced-code aliases and retains the LaTeX selector', () => {
const languages = shikiLanguages('github-light')
expect(languages.find(item => item.name === 'python')?.alias).toContain('py')
expect(languages.find(item => item.name === 'latex')?.alias).toContain('latex')
})
it('offers every bundled Shiki language and alias', () => {
const languages = shikiLanguages('github-light')
expect(languages).toHaveLength(bundledLanguagesInfo.length + 1)
for (const info of bundledLanguagesInfo) {
const language = languages.find(item => item.alias.includes(info.id))!
expect(language, info.id).toBeDefined()
expect(language.name).toBe(info.id)
expect(language.alias).toContain(info.name.toLowerCase())
for (const alias of info.aliases ?? []) expect(language.alias).toContain(alias.toLowerCase())
}
})
it('loads every bundled grammar and produces tokens with both GitHub themes', async () => {
for (const info of bundledLanguagesInfo) {
for (const theme of ['github-light', 'github-dark'] as const) {
const tokenize = await getCodeTokenizer(theme, info.id)
const tokens = tokenize('example = 42', info.id).flat()
expect(tokens.map(token => token.content).join(''), info.id).toBe('example = 42')
expect(tokens.every(token => token.color), info.id).toBe(true)
}
}
}, 120000)
@@ -0,0 +1,64 @@
import { LanguageDescription, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view'
import { bundledLanguagesInfo } from 'shiki/langs'
import { getCodeTokenizer } from '@/utils/markdown'
type CodeTheme = 'github-light' | 'github-dark'
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
const tokenize = await getCodeTokenizer(theme, language)
const highlights = ViewPlugin.fromClass(class {
decorations: DecorationSet
constructor(view: EditorView) { this.decorations = this.highlight(view) }
update(update: ViewUpdate) {
if (update.docChanged) this.decorations = this.highlight(update.view)
}
highlight(view: EditorView): DecorationSet {
const tokens = tokenize(view.state.doc.toString(), language)
const ranges = tokens.flatMap((line, index) => {
let offset = view.state.doc.line(index + 1).from
return line.flatMap(token => {
const from = offset
offset += token.content.length
if (from === offset) return []
const fontStyle = token.fontStyle ?? 0
return [Decoration.mark({
class: 'shiki-token',
attributes: { style: `color:${token.color};font-style:${fontStyle & 1 ? 'italic' : 'normal'};font-weight:${fontStyle & 2 ? 'bold' : 'normal'};text-decoration:${fontStyle & 4 ? 'underline' : 'none'}` },
}).range(from, offset)]
})
})
return Decoration.set(ranges)
}
}, { decorations: value => value.decorations })
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
return new LanguageSupport(parser, highlights)
}
export function shikiLanguages(theme: CodeTheme): LanguageDescription[] {
return [
...bundledLanguagesInfo.map(info => LanguageDescription.of({
name: info.id,
alias: [info.name, ...(info.aliases ?? [])],
load: () => shikiLanguage(info.id, theme),
})),
LanguageDescription.of({ name: 'text', alias: ['Plain text', 'txt', 'plaintext'], load: () => shikiLanguage('text', theme) }),
]
}
const languageLabels = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.name] as const),
))
export function renderCodeLanguage(language: string): string {
return languageLabels.get(language.toLowerCase()) ?? (['text', 'txt', 'plaintext'].includes(language.toLowerCase()) ? 'Plain text' : language)
}
export function shikiEditorTheme(theme: CodeTheme) {
return EditorView.theme({
'&': { color: 'var(--color-code-text)', backgroundColor: 'var(--color-code-background)' },
'.cm-gutters': { color: 'var(--color-code-muted)', backgroundColor: 'var(--color-code-background)' },
}, { dark: theme === 'github-dark' })
}
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { Refresh, VideoPlay } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/common/AppIcon.vue'
import type { Plugin, PluginCommand } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import {
applyCommandEffect,
cleanArguments,
coerceArgument,
commandFields,
initialArguments,
missingRequiredFields,
type CommandField,
} from '@/services/pluginCommandForm'
import { useEditorStore } from '@/stores/editor'
import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace'
const props = defineProps<{ plugin: Plugin }>()
const emit = defineEmits<{ (e: 'refresh-settings'): void }>()
const router = useRouter()
const pluginStore = usePluginStore()
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const commands = ref<PluginCommand[]>([])
const argumentsByCommand = ref<Record<string, Record<string, unknown>>>({})
const loading = ref(false)
const busy = ref('')
const error = ref('')
const notice = ref('')
let loadVersion = 0
watch(() => props.plugin.plugin_id, () => { void load() }, { immediate: true })
async function load() {
const version = ++loadVersion
const pluginId = props.plugin.plugin_id
error.value = ''
loading.value = true
try {
const all = await pluginService.listPluginCommands()
if (version !== loadVersion) return
const mine = all.filter((command) => command.plugin_id === pluginId)
commands.value = mine
// 重新加载会重置表单:schema 可能已经变了,留着旧值会送出非法参数。
const next: Record<string, Record<string, unknown>> = {}
for (const command of mine) next[command.command_id] = initialArguments(command)
argumentsByCommand.value = next
} catch (reason) {
if (version === loadVersion) error.value = reason instanceof Error ? reason.message : t('命令加载失败', 'Failed to load commands')
} finally {
if (version === loadVersion) loading.value = false
}
}
function argsOf(commandId: string): Record<string, unknown> {
return argumentsByCommand.value[commandId] ?? {}
}
function fieldValue(commandId: string, field: CommandField): string {
const value = argsOf(commandId)[field.key]
if (value === undefined || value === null) return ''
return String(value)
}
function updateArgument(commandId: string, field: CommandField, raw: string) {
const target = argumentsByCommand.value[commandId] ??= {}
target[field.key] = coerceArgument(field, raw)
}
/**
* when 条件求值。缺少上下文时禁用而不是硬跑 ——
* 插件详情页没有编辑器选区,不冒充。
*/
function commandAvailable(command: PluginCommand): boolean {
if (!command.enabled) return false
return command.when.every((condition) => {
if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId)
if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId)
if (condition === 'editor.has_selection') return false
return false
})
}
function missing(command: PluginCommand): CommandField[] {
return missingRequiredFields(command, argsOf(command.command_id))
}
function canRun(command: PluginCommand): boolean {
return commandAvailable(command) && missing(command).length === 0 && busy.value !== command.command_id
}
async function execute(command: PluginCommand) {
const unfilled = missing(command)
if (unfilled.length) {
error.value = `请先填写必填参数:${unfilled.map((f) => f.title).join('、')}`
return
}
busy.value = command.command_id
error.value = ''
notice.value = ''
try {
const result = await pluginService.executePluginCommand(
command.command_id,
cleanArguments(argsOf(command.command_id)),
{
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
note_id: editorStore.currentNoteId,
file_path: editorStore.currentFilePath,
selection: null,
},
)
await applyCommandEffect(result.effect, {
navigate: (path) => router.push(path),
refresh: async (scope) => {
if (scope === 'commands') await load()
else if (scope === 'plugins') await pluginStore.loadPlugins()
else if (scope === 'workspace') await workspaceStore.refreshFileTree()
else emit('refresh-settings')
},
notify: (text) => { notice.value = text },
})
} catch (reason) {
error.value = reason instanceof Error ? reason.message : t('命令执行失败', 'Command failed')
} finally {
busy.value = ''
}
}
</script>
<template>
<div class="command-panel">
<div class="section-head">
<div>
<h3>{{ t('Plugin 命令', 'Plugin commands') }}</h3>
<p>{{ t('执行该 Plugin 注册的受控 Command Contribution;参数表单由后端声明的 JSON Schema 生成。', 'Run controlled plugin commands using the parameter form defined by the plugin.') }}</p>
</div>
<button class="button-secondary" :disabled="loading" @click="load">
<AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}
</button>
</div>
<div v-if="error" class="error-banner">{{ error }}</div>
<div v-if="notice" class="notice-banner">{{ notice }}</div>
<div v-if="commands.length" class="command-list">
<article v-for="command in commands" :key="command.command_id" class="item-card command-card">
<div class="command-head">
<div>
<strong>{{ command.title }}</strong>
<p>{{ command.description || command.command_id }}</p>
</div>
<span
class="badge"
:class="{
success: commandAvailable(command),
warning: command.enabled && !commandAvailable(command),
}"
>{{ commandAvailable(command) ? t('可执行', 'Available') : command.enabled ? t('缺少上下文', 'Missing context') : t('不可用', 'Unavailable') }}</span>
</div>
<div v-if="commandFields(command).length" class="command-fields">
<label v-for="field in commandFields(command)" :key="field.key" class="field">
<span>
{{ field.title }}
<em v-if="field.required">{{ t('必填', 'Required') }}</em>
</span>
<select
v-if="field.enum"
class="select"
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="">{{ t('请选择', 'Select') }}</option>
<option v-for="option in field.enum" :key="option" :value="option">{{ option }}</option>
</select>
<select
v-else-if="field.type === 'boolean'"
class="select"
:value="fieldValue(command.command_id, field)"
@change="updateArgument(command.command_id, field, ($event.target as HTMLSelectElement).value)"
>
<option value="false">{{ t('否', 'No') }}</option>
<option value="true">{{ t('是', 'Yes') }}</option>
</select>
<input
v-else
class="input"
:type="field.type === 'number' || field.type === 'integer' ? 'number' : 'text'"
:required="field.required"
:value="fieldValue(command.command_id, field)"
@input="updateArgument(command.command_id, field, ($event.target as HTMLInputElement).value)"
/>
<small v-if="field.description">{{ field.description }}</small>
</label>
</div>
<p v-if="commandAvailable(command) && missing(command).length" class="missing-hint">
待填写{{ missing(command).map((f) => f.title).join('、') }}
</p>
<button
class="button-primary command-run"
:disabled="!canRun(command)"
@click="execute(command)"
>
<AppIcon :icon="VideoPlay" :size="15" />
{{ busy === command.command_id ? t('执行中…', 'Running…') : t('执行命令', 'Run command') }}
</button>
</article>
</div>
<div v-else-if="!loading" class="empty-state">
<div><strong>{{ t('没有可用命令', 'No available commands') }}</strong><p>{{ t('启用 Plugin 后,已注册的命令会出现在这里。', 'Registered commands appear here after the Plugin is enabled.') }}</p></div>
</div>
</div>
</template>
<style scoped>
.command-panel { min-height: 220px; }
.section-head, .command-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); margin-bottom: var(--space-lg); }
.section-head p, .command-head p { margin-top: var(--space-xs); color: var(--color-text-tertiary); font-size: var(--font-size-sm); }
.section-head button, .command-run { display: inline-flex; align-items: center; gap: var(--space-xs); }
.command-list, .command-card { display: grid; gap: var(--space-sm); }
.command-card:hover { transform: none; }
.command-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); }
.command-fields small { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
.command-run { justify-self: end; }
.missing-hint { color: var(--color-warning); font-size: var(--font-size-sm); }
em { margin-left: var(--space-xs); color: var(--color-error); font-size: var(--font-size-xs); font-style: normal; }
@media (max-width: 800px) { .command-fields { grid-template-columns: 1fr; } }
</style>
@@ -1,28 +1,22 @@
<script setup lang="ts">
import { Key, Refresh, VideoPlay } from '@element-plus/icons-vue'
import { Key, Refresh } from '@element-plus/icons-vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppIcon from '@/components/common/AppIcon.vue'
import type { Plugin, PluginCommand, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
import PluginCommandPanel from './PluginCommandPanel.vue'
import type { Plugin, PluginHostStatus, PluginSettingField, PluginSettingsSchema } from '@/contracts'
import * as pluginService from '@/services/pluginService'
import { useEditorStore } from '@/stores/editor'
import { usePluginStore } from '@/stores/plugin'
import { useWorkspaceStore } from '@/stores/workspace'
import { t, localeTag } from '@/i18n'
const props = defineProps<{ plugin: Plugin }>()
const pluginStore = usePluginStore()
const editorStore = useEditorStore()
const workspaceStore = useWorkspaceStore()
const router = useRouter()
const activeTab = ref<'host' | 'settings' | 'commands'>('host')
const host = ref<PluginHostStatus | null>(null)
const schema = ref<PluginSettingsSchema | null>(null)
const values = ref<Record<string, unknown>>({})
// 明文只停留在组件内存,提交后立即清空。
const secrets = ref<Record<string, string>>({})
const commands = ref<PluginCommand[]>([])
const argumentsByCommand = ref<Record<string, Record<string, unknown>>>({})
const loading = ref(false)
const busy = ref('')
const error = ref('')
@@ -43,7 +37,6 @@ watch(() => props.plugin.plugin_id, () => {
schema.value = null
values.value = {}
secrets.value = {}
commands.value = []
void loadActive()
}, { immediate: true })
@@ -73,19 +66,20 @@ async function loadActive() {
values.value = { ...loadedSchema.values }
}
}
if (tab === 'commands') {
const loadedCommands = (await pluginService.listPluginCommands()).filter((command) => command.plugin_id === pluginId)
if (version === loadVersion) {
commands.value = loadedCommands
for (const command of loadedCommands) argumentsByCommand.value[command.command_id] = {}
}
}
// commands 由 PluginCommandPanel 自己加载。
} catch (reason) {
if (version === loadVersion) feedback(message(reason, t('MCP 数据加载失败', 'Failed to load MCP data')))
} finally {
if (version === loadVersion) loading.value = false
}
}
/** 命令返回 refresh:settings 时重新拉设置。 */
async function reloadSettings() {
const loadedSchema = await pluginService.getPluginSettings(props.plugin.plugin_id)
schema.value = loadedSchema
values.value = { ...loadedSchema.values }
}
async function restartHost() {
busy.value = 'host'
feedback()
@@ -132,54 +126,6 @@ async function deleteSecret(field: PluginSettingField) {
notice.value = field.label + t('已删除。', ' deleted.')
} catch (reason) { feedback(message(reason, t('密钥删除失败', 'Failed to delete secret'))) } finally { busy.value = '' }
}
function properties(command: PluginCommand): Record<string, Record<string, unknown>> {
const result = command.parameters.properties
return result && typeof result === 'object' && !Array.isArray(result) ? result as Record<string, Record<string, unknown>> : {}
}
function required(command: PluginCommand, key: string) {
return Array.isArray(command.parameters.required) && command.parameters.required.includes(key)
}
function commandAvailable(command: PluginCommand) {
if (!command.enabled) return false
return command.when.every((condition) => {
if (condition === 'workspace.has_vault') return Boolean(workspaceStore.vaultId)
if (condition === 'editor.has_note') return Boolean(editorStore.currentNoteId)
// Plugin 详情页不冒充编辑器选区;选区命令应从命令面板或编辑器挂载点执行。
if (condition === 'editor.has_selection') return false
return false
})
}
function updateArgument(commandId: string, key: string, raw: string, definition: Record<string, unknown>) {
const target = argumentsByCommand.value[commandId] ??= {}
if (definition.type === 'number' || definition.type === 'integer') target[key] = raw === '' ? undefined : Number(raw)
else if (definition.type === 'boolean') target[key] = raw === 'true'
else target[key] = raw
}
async function execute(command: PluginCommand) {
busy.value = command.command_id
feedback()
try {
const result = await pluginService.executePluginCommand(command.command_id, argumentsByCommand.value[command.command_id] ?? {}, {
vault_id: workspaceStore.hasVault ? workspaceStore.vaultId : null,
note_id: editorStore.currentNoteId,
file_path: editorStore.currentFilePath,
selection: null,
})
if (result.effect.type === 'notification') notice.value = result.effect.payload.message
else if (result.effect.type === 'job') notice.value = t('后台任务已创建:', 'Background job created: ') + result.effect.payload.job_id
else if (result.effect.type === 'navigate') {
const routes: Record<string, string> = {
'vault-entry': '/', workspace: '/workspace', search: '/search', chat: '/chat',
agent: '/agent/runs', tasks: '/tasks', skills: '/extensions/skills',
plugins: '/extensions/plugins', themes: '/themes', settings: '/settings',
}
await router.push(routes[result.effect.payload.route])
} else if (result.effect.type === 'refresh') {
await loadActive()
notice.value = t('相关数据已刷新。', 'Related data refreshed.')
} else notice.value = t('命令执行完成。', 'Command completed.')
} catch (reason) { feedback(message(reason, t('命令执行失败', 'Command failed'))) } finally { busy.value = '' }
}
</script>
<template>
@@ -223,17 +169,7 @@ async function execute(command: PluginCommand) {
</div>
<div v-else class="mcp-section">
<div class="section-head"><div><h3>{{ t('Plugin 命令', 'Plugin commands') }}</h3><p>{{ t('执行该 Plugin 注册的受控 Command Contribution。', 'Run controlled command contributions registered by this Plugin.') }}</p></div><button class="button-secondary" :disabled="loading" @click="loadActive"><AppIcon :icon="Refresh" :size="15" />{{ t('刷新', 'Refresh') }}</button></div>
<div v-if="commands.length" class="command-list">
<article v-for="command in commands" :key="command.command_id" class="item-card command-card">
<div class="command-head"><div><strong>{{ command.title }}</strong><p>{{ command.description || command.command_id }}</p></div><span class="badge" :class="{ success: commandAvailable(command), warning: command.enabled && !commandAvailable(command) }">{{ commandAvailable(command) ? t('可执行', 'Available') : command.enabled ? t('缺少上下文', 'Missing context') : t('不可用', 'Unavailable') }}</span></div>
<div v-if="Object.keys(properties(command)).length" class="command-fields">
<label v-for="(definition, key) in properties(command)" :key="key" class="field"><span>{{ String(definition.title || key) }}<em v-if="required(command, key)">{{ t('必填', 'Required') }}</em></span><select v-if="Array.isArray(definition.enum)" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="">{{ t('请选择', 'Select') }}</option><option v-for="option in definition.enum" :key="String(option)" :value="String(option)">{{ option }}</option></select><select v-else-if="definition.type === 'boolean'" class="select" @change="updateArgument(command.command_id, key, ($event.target as HTMLSelectElement).value, definition)"><option value="false">{{ t('否', 'No') }}</option><option value="true">{{ t('是', 'Yes') }}</option></select><input v-else class="input" :type="definition.type === 'number' || definition.type === 'integer' ? 'number' : 'text'" @input="updateArgument(command.command_id, key, ($event.target as HTMLInputElement).value, definition)"></label>
</div>
<button class="button-primary command-run" :disabled="!commandAvailable(command) || busy === command.command_id" @click="execute(command)"><AppIcon :icon="VideoPlay" :size="15" />{{ busy === command.command_id ? t('执行中…', 'Running…') : t('执行命令', 'Run command') }}</button>
</article>
</div>
<div v-else-if="!loading" class="empty-state"><div><strong>{{ t('没有可用命令', 'No available commands') }}</strong><p>{{ t('启用 Plugin 后,已注册的命令会出现在这里。', 'Registered commands appear here after the Plugin is enabled.') }}</p></div></div>
<PluginCommandPanel :plugin="plugin" @refresh-settings="reloadSettings" />
</div>
</section>
</template>
@@ -0,0 +1,128 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import type { PluginSettingsSchema } from '@/contracts'
import * as service from '@/services/pluginService'
import PluginSettingsPanel from './PluginSettingsPanel.vue'
vi.mock('@/services/pluginService', () => ({ getPluginSettings: vi.fn(), updatePluginSettings: vi.fn(), putPluginSecret: vi.fn(), deletePluginSecret: vi.fn() }))
const schema = (value = ''): PluginSettingsSchema => ({ plugin_id: 'demo', schema_version: 1, fields: [{ key: 'name', label: 'Name', type: 'string', description: '', required: false, options: [] }], values: { name: value }, secrets: {} })
let wrapper: VueWrapper
beforeEach(() => { vi.resetAllMocks(); vi.mocked(service.getPluginSettings).mockResolvedValue(schema()) })
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
function secretSchema(configured = false): PluginSettingsSchema {
return { ...schema(), fields: [{ key: 'token', label: 'Token', type: 'secret', description: '', required: false, options: [] }], secrets: { token: { configured } } }
}
it('preserves new secret input during a pending save and allows saving it next', async () => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema())
let finish!: (value: Awaited<ReturnType<typeof service.putPluginSecret>>) => void
vi.mocked(service.putPluginSecret).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('first-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
await wrapper.get('input[type="password"]').setValue('second-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
expect(service.putPluginSecret).toHaveBeenCalledTimes(1)
finish({ plugin_id: 'demo', key: 'token', configured: true })
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('second-fixture-value')
vi.mocked(service.putPluginSecret).mockResolvedValueOnce({ plugin_id: 'demo', key: 'token', configured: true })
await wrapper.get('.secret-row button').trigger('click')
await flushPromises()
expect(service.putPluginSecret).toHaveBeenLastCalledWith('demo', 'token', 'second-fixture-value')
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('')
})
it('retains a secret draft on failure and allows retry', async () => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema())
vi.mocked(service.putPluginSecret).mockRejectedValueOnce(new Error('Save failed'))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('retry-fixture-value')
await wrapper.get('.secret-row button').trigger('click')
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('retry-fixture-value')
expect(wrapper.get('.secret-row button').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('Save failed')
})
it.each(['save', 'delete'] as const)('ignores old secret %s responses after switching plugins', async action => {
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(true))
let finish!: () => void
vi.mocked(service.putPluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: true }) }))
if (action === 'delete') {
vi.stubGlobal('confirm', vi.fn(() => true))
vi.mocked(service.deletePluginSecret).mockReturnValue(new Promise(resolve => { finish = () => resolve({ plugin_id: 'demo', key: 'token', configured: false }) }))
}
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('old-fixture-value')
await wrapper.get(action === 'save' ? '.secret-row button' : '.secret-row .danger').trigger('click')
vi.mocked(service.getPluginSettings).mockResolvedValue(secretSchema(false))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
await wrapper.get('input[type="password"]').setValue('new-fixture-value')
finish()
await flushPromises()
expect((wrapper.get('input[type="password"]').element as HTMLInputElement).value).toBe('new-fixture-value')
expect(wrapper.find('.secret-status').classes()).toContain('not-configured')
expect(wrapper.emitted('saved')).toBeUndefined()
vi.restoreAllMocks()
})
it('retains edits made during a save and submits them on the next save', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('first edit')
await wrapper.get('.form-actions button').trigger('click')
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'first edit' })
await wrapper.get('input').setValue('second edit')
resolveSave(schema('first edit'))
await flushPromises()
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect(wrapper.get('.form-actions button').attributes('disabled')).toBeUndefined()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('second edit')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('second edit'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenLastCalledWith('demo', 1, { name: 'second edit' })
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('keeps input and permits retry after a failed save', async () => {
vi.mocked(service.updatePluginSettings).mockRejectedValueOnce(new Error('Save failed'))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('retry me')
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(wrapper.find('.unsaved-hint').exists()).toBe(true)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('retry me')
vi.mocked(service.updatePluginSettings).mockResolvedValueOnce(schema('retry me'))
await wrapper.get('.form-actions button').trigger('click')
await flushPromises()
expect(service.updatePluginSettings).toHaveBeenCalledTimes(2)
expect(wrapper.find('.unsaved-hint').exists()).toBe(false)
})
it('ignores a save response after switching to another plugin', async () => {
let resolveSave!: (value: PluginSettingsSchema) => void
vi.mocked(service.updatePluginSettings).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
wrapper = mount(PluginSettingsPanel, { props: { pluginId: 'demo' } })
await flushPromises()
await wrapper.get('input').setValue('old plugin')
await wrapper.get('.form-actions button').trigger('click')
vi.mocked(service.getPluginSettings).mockResolvedValueOnce(schema('new plugin'))
await wrapper.setProps({ pluginId: 'other' })
await flushPromises()
resolveSave(schema('old plugin'))
await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('new plugin')
expect(wrapper.emitted('saved')).toBeUndefined()
})
@@ -0,0 +1,433 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import type { PluginSettingsSchema, PluginSettingField } from '@/contracts'
import {
getPluginSettings,
updatePluginSettings,
putPluginSecret,
deletePluginSecret,
} from '@/services/pluginService'
const props = defineProps<{
pluginId: string
}>()
const emit = defineEmits<{
(e: 'saved'): void
(e: 'error', message: string): void
}>()
const schema = ref<PluginSettingsSchema | null>(null)
const values = reactive<Record<string, unknown>>({})
const secrets = reactive<Record<string, string>>({})
const isLoading = ref(false)
const isSaving = ref(false)
const saveError = ref('')
const hasChanges = ref(false)
let editVersion = 0
let loadVersion = 0
const nonSecretFields = computed(() =>
schema.value?.fields.filter((f) => f.type !== 'secret') ?? []
)
const secretFields = computed(() =>
schema.value?.fields.filter((f) => f.type === 'secret') ?? []
)
async function load() {
const version = ++loadVersion
const pluginId = props.pluginId
isLoading.value = true
isSaving.value = false
saveError.value = ''
schema.value = null
Object.keys(secrets).forEach(key => delete secrets[key])
try {
const loaded = await getPluginSettings(pluginId)
if (version !== loadVersion) return
schema.value = loaded
Object.keys(values).forEach((k) => delete values[k])
Object.assign(values, schema.value.values)
hasChanges.value = false
editVersion = 0
} catch (error) {
if (version === loadVersion) emit('error', error instanceof Error ? error.message : '设置加载失败')
} finally {
if (version === loadVersion) isLoading.value = false
}
}
async function save() {
if (!schema.value || isSaving.value) return
const version = loadVersion
const submittedEditVersion = editVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
const saved = await updatePluginSettings(
pluginId,
schema.value.schema_version,
{ ...values }
)
if (version !== loadVersion) return
schema.value = saved
hasChanges.value = editVersion !== submittedEditVersion
emit('saved')
} catch (error) {
if (version === loadVersion) saveError.value = error instanceof Error ? error.message : '保存失败'
} finally {
if (version === loadVersion) isSaving.value = false
}
}
async function saveSecret(key: string) {
if (!schema.value || !secrets[key] || isSaving.value) return
const version = loadVersion
const pluginId = props.pluginId
const submittedSecret = secrets[key]
isSaving.value = true
saveError.value = ''
try {
const result = await putPluginSecret(pluginId, key, submittedSecret)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: result.configured }
}
if (secrets[key] === submittedSecret) secrets[key] = ''
emit('saved')
} catch (error) {
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '密钥保存失败'
} finally {
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
async function clearSecret(key: string) {
if (!schema.value || isSaving.value) return
if (!confirm(`确认删除 " ${key} " 的配置?`)) return
const version = loadVersion
const pluginId = props.pluginId
isSaving.value = true
saveError.value = ''
try {
await deletePluginSecret(pluginId, key)
if (version !== loadVersion || pluginId !== props.pluginId) return
if (schema.value) {
schema.value.secrets[key] = { configured: false }
}
emit('saved')
} catch (error) {
if (version === loadVersion && pluginId === props.pluginId) saveError.value = error instanceof Error ? error.message : '删除失败'
} finally {
if (version === loadVersion && pluginId === props.pluginId) isSaving.value = false
}
}
function setFieldValue(key: string, value: unknown, field: PluginSettingField) {
if (field.type === 'number') {
const num = Number(value)
if (field.minimum != null && num < field.minimum) return
if (field.maximum != null && num > field.maximum) return
values[key] = num
} else {
values[key] = value
}
hasChanges.value = true
editVersion++
}
onMounted(load)
onBeforeUnmount(() => { loadVersion++ })
watch(() => props.pluginId, load)
</script>
<template>
<div class="plugin-settings-panel">
<div v-if="isLoading" class="loading">加载设置中</div>
<template v-else-if="schema && schema.fields.length > 0">
<div v-if="saveError" class="error-banner small">{{ saveError }}</div>
<div v-if="nonSecretFields.length" class="settings-section">
<h4>通用设置</h4>
<div class="form-grid">
<div v-for="field in nonSecretFields" :key="field.key" class="field">
<label>
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<small v-if="field.description">{{ field.description }}</small>
<input
v-if="field.type === 'string'"
:value="values[field.key] ?? ''"
class="input"
@input="setFieldValue(field.key, ($event.target as HTMLInputElement).value, field)"
/>
<input
v-else-if="field.type === 'number'"
type="number"
:value="values[field.key] ?? field.default ?? 0"
:min="field.minimum ?? undefined"
:max="field.maximum ?? undefined"
class="input"
@input="setFieldValue(field.key, ($event.target as HTMLInputElement).value, field)"
/>
<label v-else-if="field.type === 'boolean'" class="switch-label">
<input
type="checkbox"
:checked="Boolean(values[field.key] ?? field.default)"
@change="setFieldValue(field.key, ($event.target as HTMLInputElement).checked, field)"
/>
<span class="switch-track"><span class="switch-thumb"></span></span>
<span class="switch-text">{{ values[field.key] ? '已启用' : '已禁用' }}</span>
</label>
<select
v-else-if="field.type === 'select'"
:value="String(values[field.key] ?? field.default ?? '')"
class="select"
@change="setFieldValue(field.key, ($event.target as HTMLSelectElement).value, field)"
>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</select>
</div>
</div>
<div class="form-actions">
<button
class="button-primary"
:disabled="!hasChanges || isSaving"
@click="save"
>
{{ isSaving ? '保存中…' : '保存设置' }}
</button>
<span v-if="hasChanges" class="unsaved-hint">有未保存的更改</span>
</div>
</div>
<div v-if="secretFields.length" class="settings-section">
<h4>密钥与凭据</h4>
<p class="section-hint">密钥加密存储前端不会回显明文</p>
<div class="form-grid">
<div v-for="field in secretFields" :key="field.key" class="field secret-field">
<label>{{ field.label }}</label>
<small v-if="field.description">{{ field.description }}</small>
<div class="secret-row">
<span
class="secret-status"
:class="schema.secrets[field.key]?.configured ? 'configured' : 'not-configured'"
>
{{ schema.secrets[field.key]?.configured ? '● 已配置' : '○ 未配置' }}
</span>
<template v-if="schema.secrets[field.key]?.configured">
<input
v-model="secrets[field.key]"
type="password"
placeholder="重新输入以更新"
class="input"
/>
<button class="button-secondary" :disabled="!secrets[field.key] || isSaving" @click="saveSecret(field.key)">
更新
</button>
<button class="link-btn danger" :disabled="isSaving" @click="clearSecret(field.key)">清除</button>
</template>
<template v-else>
<input
v-model="secrets[field.key]"
type="password"
placeholder="请输入密钥"
class="input"
/>
<button
class="button-primary"
:disabled="!secrets[field.key] || isSaving"
@click="saveSecret(field.key)"
>保存</button>
</template>
</div>
</div>
</div>
</div>
</template>
<div v-else class="empty-hint">
<p>此插件没有可配置项</p>
</div>
</div>
</template>
<style scoped>
.plugin-settings-panel {
display: grid;
gap: var(--space-lg);
}
.settings-section h4 {
margin-bottom: var(--space-sm);
font-size: var(--font-size-md);
}
.section-hint {
font-size: var(--font-size-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--space-md);
}
.form-grid {
display: grid;
gap: var(--space-md);
}
.field {
display: grid;
gap: 4px;
}
.field label {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-weight: 500;
}
.field small {
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.required {
color: var(--color-error);
margin-left: 2px;
}
.input, .select {
padding: 6px 10px;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
width: 100%;
transition: border-color var(--motion-fast);
}
.input:focus, .select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-primary) 15%, transparent);
}
.switch-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
font-weight: 400 !important;
}
.switch-label input { display: none; }
.switch-track {
position: relative;
width: 40px;
height: 22px;
border-radius: 11px;
background: var(--color-background-tertiary);
transition: background-color var(--motion-fast);
}
.switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-text-inverse);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform var(--motion-fast);
}
.switch-label input:checked + .switch-track {
background: var(--color-accent-primary);
}
.switch-label input:checked + .switch-track .switch-thumb {
transform: translateX(18px);
}
.switch-text {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.form-actions {
display: flex;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-md);
}
.unsaved-hint {
font-size: var(--font-size-xs);
color: var(--color-warning);
}
.secret-field .secret-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: 4px;
}
.secret-status {
font-size: var(--font-size-xs);
padding: 2px 8px;
border-radius: var(--radius-full);
white-space: nowrap;
}
.secret-status.configured {
background: var(--color-success-soft);
color: var(--color-success);
}
.secret-status.not-configured {
background: var(--color-background-tertiary);
color: var(--color-text-tertiary);
}
.secret-row .input {
flex: 1;
min-width: 0;
}
.error-banner.small {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
}
.loading, .empty-hint {
padding: var(--space-xl);
text-align: center;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.link-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
padding: 0;
}
.link-btn.danger { color: var(--color-error); }
.link-btn:hover { text-decoration: underline; }
</style>
+273 -22
View File
@@ -2,50 +2,301 @@
import { Connection } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import PluginMcpPanel from './PluginMcpPanel.vue'
import { onMounted, ref } from 'vue'
import PluginCommandPanel from './PluginCommandPanel.vue'
import PluginSettingsPanel from './PluginSettingsPanel.vue'
import { computed, onMounted, ref, watch } from 'vue'
import { usePluginStore } from '@/stores/plugin'
import * as pluginService from '@/services/pluginService'
import type { PluginCommand } from '@/contracts'
import { t } from '@/i18n'
const pluginStore = usePluginStore()
const actionError = ref('')
const activeTab = ref<'info' | 'settings' | 'commands'>('info')
const pluginCommands = ref<PluginCommand[]>([])
onMounted(() => { void pluginStore.loadPlugins() })
async function install() { const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim(); if (!path) return; try { await pluginStore.installPlugin(path) } catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') } }
async function toggle(id: string, enabled: boolean) { try { enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') } }
async function grant(id: string, permissions: string[]) { if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return; try { await pluginStore.grantPermissions(id, permissions) } catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') } }
async function uninstall(id: string, name: string) { if (!confirm(t(`卸载“${name}”将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return; try { await pluginStore.uninstallPlugin(id) } catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') } }
watch(() => pluginStore.selectedPluginId, async (pluginId) => {
if (pluginId) {
activeTab.value = 'info'
pluginCommands.value = []
try {
// 只为了标签上的命令数;执行逻辑在 PluginCommandPanel 里。
const allCommands = await pluginService.listPluginCommands()
pluginCommands.value = allCommands.filter((c) => c.plugin_id === pluginId)
} catch { /* 命令加载失败时忽略 */ }
}
})
async function install() {
const path = prompt(t('请输入 Plugin Package 路径', 'Enter the Plugin Package path'))?.trim()
if (!path) return
try { await pluginStore.installPlugin(path) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('安装失败', 'Installation failed') }
}
async function toggle(id: string, enabled: boolean) {
try {
enabled ? await pluginStore.disablePlugin(id) : await pluginStore.enablePlugin(id)
} catch (error) { actionError.value = error instanceof Error ? error.message : t('状态更新失败', 'Status update failed') }
}
async function grant(id: string, permissions: string[]) {
if (!confirm(`${t('将授权:', 'Grant permissions: ')}${permissions.join(', ')}${t('是否继续?', 'Continue?')}`)) return
try { await pluginStore.grantPermissions(id, permissions) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('授权失败', 'Authorization failed') }
}
async function uninstall(id: string, name: string) {
if (!confirm(t(`卸载「${name}」将移除其全部 Contribution,是否继续?`, `Uninstalling “${name}” removes all its contributions. Continue?`))) return
try { await pluginStore.uninstallPlugin(id) }
catch (error) { actionError.value = error instanceof Error ? error.message : t('卸载失败', 'Uninstall failed') }
}
const hasSettingsContribution = computed(() =>
pluginStore.selectedPlugin?.contributions.some((c) => c.type === 'settings_section') ?? false
)
const hasCommandContribution = computed(() =>
pluginStore.selectedPlugin?.contributions.some((c) => c.type === 'command') ?? false
)
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div><button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button></header>
<div v-if="pluginStore.error || actionError" class="error-banner">{{ pluginStore.error || actionError }}</div>
<div v-if="pluginStore.selectedPlugin" class="panel">
<div class="detail-head"><div><span class="badge" :class="{ success: pluginStore.selectedPlugin.status === 'ready', error: pluginStore.selectedPlugin.status === 'error', warning: pluginStore.selectedPlugin.status === 'permission_required' }">{{ pluginStore.selectedPlugin.status }}</span><h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2><p class="muted">v{{ pluginStore.selectedPlugin.version }} · {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}</p></div><div class="inline-actions"><button v-if="pluginStore.selectedPlugin.status === 'permission_required'" class="button-primary" @click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)">{{ t('授权权限', 'Grant permissions') }}</button><button class="button-secondary" @click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)">{{ pluginStore.selectedPlugin.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button><button class="button-danger" @click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)">{{ t('卸载', 'Uninstall') }}</button></div></div>
<p class="description">{{ pluginStore.selectedPlugin.description }}</p>
<div class="detail-grid"><div><h3>{{ t('权限', 'Permissions') }}</h3><div class="tag-list"><span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">{{ permission }}</span></div></div><div><h3>Contribution</h3><div class="contribution-list"><div v-for="item in pluginStore.selectedPlugin.contributions" :key="item.id" class="item-card"><span class="badge info">{{ item.type }}</span><strong>{{ item.name }}</strong><p class="subtle">{{ item.description || item.id }}</p></div></div></div></div>
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">{{ pluginStore.selectedPlugin.last_error }}</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner last-error">{{ t('依赖此插件的 Skill', 'Skills that depend on this plugin: ') }}{{ pluginStore.selectedPlugin.dependent_skills.join(', ') }}</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
<header class="feature-header">
<div><h1>{{ t('Plugin 与 MCP', 'Plugins and MCP') }}</h1><p>{{ t('管理插件生命周期、MCP Host、权限和受控 Contribution。', 'Manage plugin lifecycles, MCP hosts, permissions, and controlled contributions.') }}</p></div>
<button class="button-primary" @click="install">{{ t('安装 Plugin', 'Install Plugin') }}</button>
</header>
<div v-if="pluginStore.error || actionError" class="error-banner">
{{ pluginStore.error || actionError }}
</div>
<div v-if="pluginStore.selectedPlugin" class="plugin-detail">
<div class="panel detail-panel">
<div class="detail-head">
<div>
<span class="badge" :class="{
success: pluginStore.selectedPlugin.status === 'ready',
error: pluginStore.selectedPlugin.status === 'error',
warning: pluginStore.selectedPlugin.status === 'permission_required',
info: pluginStore.selectedPlugin.status === 'starting',
}">{{ pluginStore.selectedPlugin.status }}</span>
<h2>{{ pluginStore.selectedPlugin.icon }} {{ pluginStore.selectedPlugin.name }}</h2>
<p class="muted">
v{{ pluginStore.selectedPlugin.version }}
· {{ pluginStore.selectedPlugin.backend_type || 'none' }}/{{ pluginStore.selectedPlugin.transport || 'none' }}
· {{ pluginStore.selectedPlugin.author || '未知作者' }}
</p>
</div>
<div class="inline-actions">
<button
v-if="pluginStore.selectedPlugin.status === 'permission_required'"
class="button-primary"
@click="grant(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.permissions)"
>{{ t('授权权限', 'Grant permissions') }}</button>
<button
class="button-secondary"
@click="toggle(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.enabled)"
>{{ pluginStore.selectedPlugin.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button>
<button
class="button-danger"
@click="uninstall(pluginStore.selectedPlugin.plugin_id, pluginStore.selectedPlugin.name)"
>{{ t('卸载', 'Uninstall') }}</button>
</div>
</div>
<p class="description">{{ pluginStore.selectedPlugin.description }}</p>
<div class="detail-tabs">
<button
class="tab-btn"
:class="{ active: activeTab === 'info' }"
@click="activeTab = 'info'"
>{{ t('概览', 'Overview') }}</button>
<button
v-if="hasCommandContribution"
class="tab-btn"
:class="{ active: activeTab === 'commands' }"
@click="activeTab = 'commands'"
>{{ t('命令', 'Commands') }} ({{ pluginCommands.length }})</button>
<button
v-if="hasSettingsContribution || pluginCommands.some(c => c.enabled)"
class="tab-btn"
:class="{ active: activeTab === 'settings' }"
@click="activeTab = 'settings'"
>{{ t('设置', 'Settings') }}</button>
</div>
<div v-if="activeTab === 'info'" class="tab-content">
<div class="detail-grid">
<div>
<h3>{{ t('权限', 'Permissions') }}</h3>
<div class="tag-list">
<span v-for="permission in pluginStore.selectedPlugin.permissions" :key="permission" class="badge warning">
{{ permission }}
</span>
</div>
</div>
<div>
<h3>Contribution</h3>
<div class="contribution-list">
<div
v-for="item in pluginStore.selectedPlugin.contributions"
:key="item.id"
class="item-card"
>
<span class="badge info">{{ item.type }}</span>
<strong>{{ item.name }}</strong>
<p class="subtle">{{ item.description || item.id }}</p>
</div>
</div>
</div>
</div>
<div v-if="pluginStore.selectedPlugin.last_error" class="error-banner last-error">
{{ pluginStore.selectedPlugin.last_error }}
</div>
<div v-if="pluginStore.selectedPlugin.dependent_skills?.length" class="notice-banner">
{{ t('依赖此插件的 Skill', 'Skills that depend on this plugin: ') }}{{ pluginStore.selectedPlugin.dependent_skills.join(', ') }}
</div>
<PluginMcpPanel :plugin="pluginStore.selectedPlugin" />
</div>
<div v-else-if="activeTab === 'commands'" class="tab-content">
<PluginCommandPanel :plugin="pluginStore.selectedPlugin" />
</div>
<div v-else-if="activeTab === 'settings'" class="tab-content">
<PluginSettingsPanel :plugin-id="pluginStore.selectedPlugin.plugin_id" />
</div>
</div>
</div>
<div v-else-if="!pluginStore.plugins.length" class="empty-state">
<div>
<strong>{{ pluginStore.isLoading ? t('正在加载…', 'Loading…') : pluginStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'No plugins installed') }}</strong>
<button class="button-secondary" @click="pluginStore.loadPlugins">{{ t('重新加载', 'Reload') }}</button>
</div>
</div>
<div v-else class="feature-grid">
<article
v-for="plugin in pluginStore.plugins"
:key="plugin.plugin_id"
class="item-card extension-card"
@click="pluginStore.selectPlugin(plugin.plugin_id)"
>
<div class="extension-title">
<AppIcon :icon="Connection" :size="22" />
<div>
<strong>{{ plugin.name }}</strong>
<p>v{{ plugin.version }}</p>
</div>
<span
class="badge"
:class="{
success: plugin.status === 'ready',
error: plugin.status === 'error',
warning: plugin.status === 'permission_required',
}"
>{{ plugin.status }}</span>
</div>
<p class="muted">{{ plugin.description }}</p>
<p class="subtle">
{{ plugin.permissions.length }} {{ t('项权限', 'permissions') }} · {{ plugin.contributions.length }} {{ t('项 Contribution', 'contributions') }}
</p>
</article>
</div>
<div v-else-if="!pluginStore.plugins.length" class="empty-state"><div><strong>{{ pluginStore.isLoading ? t('正在加载…', 'Loading…') : pluginStore.error ? t('加载失败', 'Load failed') : t('尚未安装', 'No plugins installed') }}</strong><button class="button-secondary" @click="pluginStore.loadPlugins">{{ t('重新加载', 'Reload') }}</button></div></div>
<div v-else class="feature-grid"><article v-for="plugin in pluginStore.plugins" :key="plugin.plugin_id" class="item-card extension-card" @click="pluginStore.selectPlugin(plugin.plugin_id)"><div class="extension-title"><AppIcon :icon="Connection" :size="22" /><div><strong>{{ plugin.name }}</strong><p>v{{ plugin.version }}</p></div><span class="badge" :class="{ success: plugin.status === 'ready', error: plugin.status === 'error', warning: plugin.status === 'permission_required' }">{{ plugin.status }}</span></div><p class="muted">{{ plugin.description }}</p><p class="subtle">{{ plugin.permissions.length }} {{ t('项权限', 'permissions') }} · {{ plugin.contributions.length }} {{ t(' Contribution', 'contributions') }}</p></article></div>
</section>
</template>
<style scoped>
.detail-head, .extension-title { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-md); }
.plugin-detail { display: grid; gap: var(--space-lg); }
.detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
}
.detail-head h2 { margin-top: var(--space-sm); }
.description { margin: var(--space-xl) 0; line-height: var(--line-height-relaxed); }
.detail-grid { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: var(--space-xl); }
.detail-head .muted {
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
margin-top: 4px;
}
.description {
margin: var(--space-xl) 0;
line-height: var(--line-height-relaxed);
}
.detail-tabs {
display: flex;
gap: var(--space-sm);
border-bottom: 1px solid var(--color-border-default);
margin-bottom: var(--space-lg);
}
.tab-btn {
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--font-size-md);
margin-bottom: -1px;
transition: all var(--motion-fast);
}
.tab-btn:hover { color: var(--color-text-primary); }
.tab-btn.active {
color: var(--color-accent-primary);
border-bottom-color: var(--color-accent-primary);
font-weight: 500;
}
.tab-content { min-height: 200px; }
.detail-grid {
display: grid;
grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr);
gap: var(--space-xl);
}
.detail-grid h3 { margin-bottom: var(--space-sm); }
.contribution-list { display: grid; gap: var(--space-sm); }
.contribution-list .item-card { display: grid; gap: var(--space-xs); }
.tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
.last-error { margin: var(--space-xl) 0 0; }
.notice-banner {
margin-top: var(--space-xl);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
background: var(--color-info-soft);
color: var(--color-info);
font-size: var(--font-size-sm);
}
.extension-card { cursor: pointer; }
.extension-card > p { margin-top: var(--space-md); }
.extension-title { align-items: center; }
.extension-title .icon { font-size: 28px; }
.extension-title { display: flex; align-items: center; gap: var(--space-sm); }
.extension-title div { flex: 1; }
.extension-title p { color: var(--color-text-tertiary); font-size: var(--font-size-xs); }
@media (max-width: 800px) { .detail-grid { grid-template-columns: 1fr; } }
.empty-hint {
padding: var(--space-2xl);
text-align: center;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
@media (max-width: 800px) {
.detail-grid { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw'
const props = defineProps<{ themeId: string }>()
const emit = defineEmits<{ (event: 'close'): void }>()
const theme = computed(() => mockCommunityThemes.find(item => item.theme_id === props.themeId))
const previewDocument = computed(() => {
// Only bundled community CSS enters this script-free, isolated document.
// Previewing never installs a theme or changes application styles/storage.
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
doc.documentElement.dataset.theme = props.themeId
const style = doc.createElement('style')
style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
doc.head.append(style)
const article = doc.createElement('article')
const heading = doc.createElement('h1'); heading.textContent = theme.value?.name ?? props.themeId
const text = doc.createElement('p'); text.textContent = t('知识的价值不只在于保存,更在于被重新发现和使用。', 'Knowledge gains value when it can be rediscovered and used.')
const button = doc.createElement('button'); button.textContent = t('示例按钮', 'Example button')
article.append(heading, text, button); doc.body.append(article)
return '<!doctype html>' + doc.documentElement.outerHTML
})
</script>
<template>
<div class="modal-backdrop" @click.self="emit('close')" @keydown.esc="emit('close')">
<section class="modal theme-preview-dialog" role="dialog" aria-modal="true" :aria-label="t('社区主题预览', 'Community theme preview')">
<div class="preview-heading"><h2>{{ theme?.name }}</h2><button class="button-secondary" autofocus @click="emit('close')">{{ t('关闭预览', 'Close preview') }}</button></div>
<iframe :title="`${t('主题预览', 'Theme preview')}: ${theme?.name ?? themeId}`" sandbox="" :srcdoc="previewDocument" />
<p class="subtle">{{ t('仅预览,不会安装或更改当前主题。', 'Preview only. Your installed themes and current appearance remain unchanged.') }}</p>
</section>
</div>
</template>
<style scoped>
.theme-preview-dialog { width: min(720px, calc(100vw - 32px)); }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
iframe { display: block; width: 100%; height: min(420px, 60vh); margin: 16px 0; border: 1px solid var(--color-border-default); border-radius: 8px; }
</style>
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ThemesView from './ThemesView.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes, getCommunityThemePreviewCss } from '@/services/themePackageService'
let wrapper: VueWrapper
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { wrapper?.unmount(); vi.useRealTimers() })
it.each(mockCommunityThemes)('previews uninstalled $theme_id using its actual CSS without changing the active theme', async theme => {
const store = useThemeStore()
store.applyTheme('light')
wrapper = mount(ThemesView, { global: { stubs: { MarkdownContent: true } } })
await flushPromises()
await wrapper.findAll('.tab-btn')[1]!.trigger('click')
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes(theme.name))!
await card.findAll('button').find(button => button.text() === '预览')!.trigger('click')
expect(wrapper.get('[role="dialog"]').text()).toContain(theme.name)
const frame = wrapper.get('iframe')
expect(frame.attributes('sandbox')).toBe('')
const preview = new DOMParser().parseFromString(frame.attributes('srcdoc')!, 'text/html')
expect(preview.documentElement.dataset.theme).toBe(theme.theme_id)
expect(preview.querySelector('style')!.textContent).toContain(getCommunityThemePreviewCss(theme.theme_id))
expect(store.currentThemeId).toBe('light')
expect(localStorage.getItem('theme')).toBe('light')
expect(store.isThemeInstalled(theme.theme_id)).toBe(false)
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
vi.useFakeTimers()
await wrapper.get('[role="dialog"] button').trigger('click')
store.applyTheme('dark')
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.find('iframe').exists()).toBe(false)
expect(store.currentThemeId).toBe('dark')
})
+378 -14
View File
@@ -1,28 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import { useThemeStore } from '@/stores/theme'
import { mockCommunityThemes } from '@/services/themePackageService'
import type { ThemePackageInspection } from '@/contracts'
import { t } from '@/i18n'
import CommunityThemePreview from './CommunityThemePreview.vue'
const themeStore = useThemeStore()
const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false)
const previewThemeId = ref<string | null>(null)
const communityPreviewId = ref<string | null>(null)
const actionError = ref('')
const shikiPreview = `\`\`\`typescript
const notes = await search('本地优先')
\`\`\``
const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'github-dark'
? 'Shiki · GitHub Dark'
: 'Shiki · GitHub Light')
const communityThemes = computed(() => mockCommunityThemes)
function handleFileImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
actionError.value = ''
const reader = new FileReader()
reader.onload = async () => {
try {
const result = await themeStore.inspectThemePackage(String(reader.result ?? ''))
if (result.compatible) {
previewThemeId.value = result.manifest.theme_id
} else {
actionError.value = result.warnings[0] ?? '主题包无法解析'
}
} catch (error) {
actionError.value = error instanceof Error ? error.message : '导入失败'
}
}
reader.onerror = () => { actionError.value = '文件读取失败' }
// 主题包是文本格式(YAML 清单 + --- + CSS),二进制包在解析阶段会被拒绝。
reader.readAsText(file)
}
async function confirmInstall(inspection: ThemePackageInspection) {
actionError.value = ''
try {
// 装的必须是包里那份 CSS —— 之前这里是现场生成的假样式,
// 用户提供的内容被整份丢掉了。
if (!inspection.css.trim()) throw new Error('主题包内没有 CSS 内容,无法安装。')
await themeStore.installThemeFromInspection(inspection.manifest, inspection.css)
showImportDialog.value = false
previewThemeId.value = null
} catch (error) {
actionError.value = error instanceof Error ? error.message : '安装失败'
}
}
async function installFromCommunity(themeId: string) {
actionError.value = ''
try {
await themeStore.installCommunityTheme(themeId)
} catch (error) {
actionError.value = error instanceof Error ? error.message : '安装失败'
}
}
function previewCommunity(themeId: string) {
communityPreviewId.value = themeId
}
onMounted(() => {
themeStore.loadCustomThemes()
})
</script>
<template>
<section class="feature-page">
<header class="feature-header"><div><h1>{{ t('主题', 'Themes') }}</h1><p>{{ t('预览并切换 Design Token,编辑器偏好会即时生效。', 'Preview and switch design tokens. Editor preferences apply immediately.') }}</p></div><button class="button-secondary" @click="themeStore.resetToDefault">{{ t('恢复默认', 'Reset defaults') }}</button></header>
<div class="feature-grid themes">
<button v-for="theme in themeStore.themes" :key="theme.theme_id" class="item-card theme-card" :class="{ selected: themeStore.currentThemeId === theme.theme_id }" @click="themeStore.applyTheme(theme.theme_id)">
<div class="theme-preview" :class="`preview-${theme.theme_id}`"><span></span><span></span><span></span><div></div></div>
<div class="theme-info"><div><strong>{{ theme.name }}</strong><p class="subtle">{{ theme.description }}</p></div><span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">{{ t('使用中', 'Active') }}</span></div>
<p class="subtle">v{{ theme.version }} · {{ theme.builtin ? t('内置主题', 'Built-in theme') : theme.author }}</p>
<CommunityThemePreview v-if="communityPreviewId" :theme-id="communityPreviewId" @close="communityPreviewId = null" />
<header class="feature-header">
<div>
<h1>{{ t('主题', 'Themes') }}</h1>
<p>浏览导入和管理主题打造你的知识工作流</p>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = true">导入主题</button>
<button class="button-secondary" @click="themeStore.resetToDefault()">{{ t('恢复默认', 'Reset defaults') }}</button>
</div>
</header>
<div v-if="actionError || themeStore.importError" class="error-banner">
{{ actionError || themeStore.importError }}
</div>
<div v-if="themeStore.themeLoadWarning" class="warning-banner">
{{ themeStore.themeLoadWarning }}
</div>
<div class="tabs">
<button
class="tab-btn"
:class="{ active: activeTab === 'installed' }"
@click="activeTab = 'installed'"
>已安装</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'community' }"
@click="activeTab = 'community'"
>社区主题</button>
</div>
<div v-if="activeTab === 'installed'" class="feature-grid themes">
<button
v-for="theme in themeStore.allThemes"
:key="theme.theme_id"
class="item-card theme-card"
:class="{ selected: themeStore.currentThemeId === theme.theme_id }"
@click="themeStore.applyTheme(theme.theme_id)"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : (theme.theme_id === 'sepia' ? 'preview-sepia' : 'preview-light')">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
<div>
<strong>{{ theme.name }}</strong>
<p class="subtle">{{ theme.description }}</p>
</div>
<span v-if="themeStore.currentThemeId === theme.theme_id" class="badge success">{{ t('使用中', 'Active') }}</span>
</div>
<p class="subtle">
v{{ theme.version }} · {{ theme.builtin ? t('内置主题', 'Built-in theme') : theme.author }}
<span v-if="!theme.builtin"> · 自定义</span>
</p>
<div v-if="!theme.builtin" class="theme-actions" @click.stop>
<button class="link-btn danger" @click="themeStore.uninstallTheme(theme.theme_id)">卸载</button>
</div>
</button>
</div>
<div v-else class="feature-grid themes">
<article
v-for="theme in communityThemes"
:key="theme.theme_id"
class="item-card theme-card"
>
<div class="theme-preview" :class="theme.is_dark ? 'preview-dark' : 'preview-light'">
<span></span><span></span><span></span><div></div>
</div>
<div class="theme-info">
<div>
<strong>{{ theme.name }}</strong>
<p class="subtle">{{ theme.description }}</p>
</div>
<span class="badge" :class="theme.is_dark ? 'info' : 'success'">{{ theme.is_dark ? '深色' : '浅色' }}</span>
</div>
<p class="subtle">v{{ theme.version }} · {{ theme.author }}</p>
<div class="theme-tags">
<span v-for="tag in theme.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="theme-actions">
<button
v-if="themeStore.isThemeInstalled(theme.theme_id)"
class="button-secondary small"
@click="themeStore.applyTheme(theme.theme_id)"
>启用</button>
<template v-else>
<button class="button-secondary small" @click="previewCommunity(theme.theme_id)">预览</button>
<button class="button-primary small" @click="installFromCommunity(theme.theme_id)">安装</button>
</template>
</div>
</article>
</div>
<div class="panel preference-panel">
<h2 class="panel-title">{{ t('编辑器外观', 'Editor Appearance') }}</h2>
<div class="form-grid">
@@ -37,22 +192,231 @@ const codeThemeLabel = computed(() => themeStore.resolvedCodeBlockTheme === 'git
<MarkdownContent class="code-theme-preview" :source="shikiPreview" />
</div>
</div>
<div v-if="showImportDialog" class="modal-backdrop" @click.self="showImportDialog = false">
<div class="modal import-modal">
<span class="badge info">主题导入</span>
<h2>导入主题包</h2>
<p class="subtle">单文件主题包YAML 清单 + 一行 <code>---</code> + 主题 CSS安装前会校验清单与 CSS 安全性</p>
<div v-if="themeStore.pendingInspection?.compatible" class="inspection-result">
<div class="inspect-head">
<strong>{{ themeStore.pendingInspection.manifest.name }}</strong>
<span class="badge success">验证通过</span>
</div>
<div class="inspect-meta">
<span>作者{{ themeStore.pendingInspection.manifest.author }}</span>
<span>版本{{ themeStore.pendingInspection.manifest.version }}</span>
<span>{{ themeStore.pendingInspection.manifest.is_dark ? '深色主题' : '浅色主题' }}</span>
</div>
<p v-if="themeStore.pendingInspection.manifest.description" class="inspect-desc">
{{ themeStore.pendingInspection.manifest.description }}
</p>
<div v-if="themeStore.pendingInspection.warnings.length" class="warnings">
<p v-for="w in themeStore.pendingInspection.warnings" :key="w" class="warning-text"> {{ w }}</p>
</div>
<details class="css-preview">
<summary>将要安装的 CSS{{ themeStore.pendingInspection.css.length }} 字符</summary>
<pre>{{ themeStore.pendingInspection.css }}</pre>
</details>
</div>
<div v-else class="upload-area">
<input type="file" accept=".yaml,.yml,.theme" @change="handleFileImport" />
<p>点击选择主题包文件</p>
<p class="subtle">支持 .yaml / .yml / .themeZIP 需要 Host 端解压暂不支持</p>
</div>
<div class="inline-actions">
<button class="button-secondary" @click="showImportDialog = false">取消</button>
<button
v-if="themeStore.pendingInspection?.compatible"
class="button-primary"
@click="confirmInstall(themeStore.pendingInspection!)"
>安装主题</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.themes { margin-bottom: var(--space-xl); }
.theme-card { display: grid; gap: var(--space-md); text-align: left; }
.theme-preview { display: grid; grid-template-columns: 30px 1fr; grid-template-rows: repeat(3, 18px); gap: 6px; height: 120px; padding: var(--space-md); border-radius: var(--radius-md); background: #fff; border: 1px solid #ddd; }
.theme-card { display: grid; gap: var(--space-md); text-align: left; position: relative; }
.theme-preview {
display: grid;
grid-template-columns: 30px 1fr;
grid-template-rows: repeat(3, 18px);
gap: 6px;
height: 120px;
padding: var(--space-md);
border-radius: var(--radius-md);
background: #fff;
border: 1px solid #ddd;
}
.theme-preview span { grid-column: 1; border-radius: 4px; background: #dfe3eb; }
.theme-preview div { grid-column: 2; grid-row: 1 / 4; border-radius: 6px; background: #f4f5f7; }
.preview-dark { background: #0d1117; border-color: #30363d; }.preview-dark span { background: #30363d; }.preview-dark div { background: #161b22; }
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }.preview-sepia span { background: #d8c69c; }.preview-sepia div { background: #f4e8ca; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); }
.preview-dark { background: #0d1117; border-color: #30363d; }
.preview-dark span { background: #30363d; }
.preview-dark div { background: #161b22; }
.preview-sepia { background: #fbf3df; border-color: #ddcfad; }
.preview-sepia span { background: #d8c69c; }
.preview-sepia div { background: #f4e8ca; }
.theme-info { display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; }
.theme-info strong { display: block; margin-bottom: 2px; }
.theme-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.theme-actions { display: flex; gap: var(--space-sm); margin-top: 4px; }
.button-primary.small, .button-secondary.small {
padding: 4px 12px;
font-size: var(--font-size-sm);
}
.link-btn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
padding: 0;
}
.link-btn.danger { color: var(--color-error); }
.link-btn:hover { text-decoration: underline; }
.tabs {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border-default);
}
.tab-btn {
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--font-size-md);
margin-bottom: -1px;
transition: all var(--motion-fast);
}
.tab-btn:hover { color: var(--color-text-primary); }
.tab-btn.active {
color: var(--color-accent-primary);
border-bottom-color: var(--color-accent-primary);
font-weight: 500;
}
.preference-panel { display: grid; gap: var(--space-xl); }
.editor-preview { padding: var(--space-xl); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); }
.editor-preview {
padding: var(--space-xl);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-background-secondary);
}
.editor-preview p { margin: var(--space-sm) 0; }
.preview-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.preview-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.field small { color: var(--color-text-tertiary); }
.code-theme-preview { margin-top: var(--space-md); }
.import-modal {
width: min(520px, 90vw);
max-height: 80vh;
overflow: auto;
}
.upload-area {
padding: var(--space-2xl);
border: 2px dashed var(--color-border-default);
border-radius: var(--radius-md);
text-align: center;
margin: var(--space-lg) 0;
transition: border-color var(--motion-fast);
}
.upload-area:hover { border-color: var(--color-accent-secondary); }
.upload-area input {
display: block;
margin: 0 auto var(--space-md);
}
.upload-area p { color: var(--color-text-secondary); }
.inspection-result {
padding: var(--space-lg);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
margin: var(--space-lg) 0;
background: var(--color-background-secondary);
}
.inspect-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-sm);
}
.inspect-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-sm);
}
.inspect-desc {
color: var(--color-text-primary);
line-height: var(--line-height-relaxed);
}
.warnings {
margin-top: var(--space-md);
padding-top: var(--space-sm);
border-top: 1px solid var(--color-border-default);
}
.warning-text {
color: var(--color-warning);
font-size: var(--font-size-sm);
}
.warning-banner {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-warning);
border-radius: var(--radius-md);
background: var(--color-warning-soft);
color: var(--color-warning);
font-size: var(--font-size-sm);
}
.css-preview {
margin-top: var(--space-md);
}
.css-preview summary {
cursor: pointer;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.css-preview pre {
margin-top: var(--space-sm);
max-height: 220px;
overflow: auto;
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--color-background-secondary);
font-family: var(--font-ui-mono);
font-size: var(--font-size-xs);
white-space: pre-wrap;
word-break: break-all;
}
.inline-actions { margin-top: var(--space-lg); justify-content: flex-end; gap: var(--space-sm); }
</style>
+1 -1
View File
@@ -18,7 +18,7 @@ app.use(router)
const themeStore = useThemeStore()
const settingsStore = useSettingsStore()
themeStore.initTheme()
void themeStore.initTheme()
watch(appLocale, () => updateDocumentTitle())
watch(() => settingsStore.spellCheck, (enabled) => {
document.body.spellcheck = enabled
+3
View File
@@ -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'
+126
View File
@@ -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> = { '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }
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,227 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyCommandEffect,
cleanArguments,
coerceArgument,
commandFields,
EFFECT_ROUTES,
initialArguments,
missingRequiredFields,
} from './pluginCommandForm'
import type { PluginCommand, PluginCommandEffect } from '@/contracts'
function command(parameters: Record<string, unknown>): PluginCommand {
return {
command_id: 'demo.run',
plugin_id: 'demo',
title: '示例命令',
description: '',
locations: [],
when: [],
parameters,
enabled: true,
}
}
/** 后端只接受 type=object 的 JSON Schemacontributions.py 显式拒绝其他形态)。 */
const schema = command({
type: 'object',
properties: {
path: { type: 'string', title: '笔记路径', description: '相对于库根目录' },
count: { type: 'integer', default: 3 },
recursive: { type: 'boolean' },
mode: { type: 'string', enum: ['fast', 'full'] },
},
required: ['path', 'mode'],
})
describe('commandFields', () => {
it('摊平 properties 并标记 required', () => {
const fields = commandFields(schema)
expect(fields.map((f) => f.key)).toEqual(['path', 'count', 'recursive', 'mode'])
expect(fields[0]).toMatchObject({ title: '笔记路径', type: 'string', required: true })
expect(fields[1]).toMatchObject({ type: 'integer', required: false, default: 3 })
expect(fields[3].enum).toEqual(['fast', 'full'])
})
it('没有 title 时用字段名兜底,没有 type 时按 string 处理', () => {
const fields = commandFields(command({ type: 'object', properties: { raw: {} } }))
expect(fields[0]).toMatchObject({ key: 'raw', title: 'raw', type: 'string', required: false })
})
it('parameters 为空或形态异常时返回空数组而不是抛错', () => {
expect(commandFields(command({}))).toEqual([])
expect(commandFields(command({ type: 'object' }))).toEqual([])
// properties 被写成数组等非法形态时按空处理
expect(commandFields(command({ type: 'object', properties: ['nope'] as unknown as Record<string, unknown> }))).toEqual([])
})
})
describe('initialArguments', () => {
it('布尔字段显式初始化为 false,保证 UI 显示与提交值一致', () => {
// 回归:之前布尔下拉框显示「否」,但参数对象里没有这个键,
// 用户没手动切换过就会漏发这个参数。
const args = initialArguments(schema)
expect(args.recursive).toBe(false)
expect('recursive' in args).toBe(true)
})
it('有 default 的字段用 default,没有的不塞键', () => {
const args = initialArguments(schema)
expect(args.count).toBe(3)
expect('path' in args).toBe(false)
expect('mode' in args).toBe(false)
})
it('布尔字段的 default 优先于 false', () => {
const args = initialArguments(
command({ type: 'object', properties: { flag: { type: 'boolean', default: true } } }),
)
expect(args.flag).toBe(true)
})
})
describe('coerceArgument', () => {
const field = (type: string) => ({ key: 'k', title: 'k', type, required: false })
it('布尔只认字符串 "true"', () => {
expect(coerceArgument(field('boolean'), 'true')).toBe(true)
expect(coerceArgument(field('boolean'), 'false')).toBe(false)
})
it('数字字段转成 number,空串与非法输入转成 undefined', () => {
expect(coerceArgument(field('integer'), '42')).toBe(42)
expect(coerceArgument(field('number'), '1.5')).toBe(1.5)
expect(coerceArgument(field('number'), '')).toBeUndefined()
expect(coerceArgument(field('number'), 'abc')).toBeUndefined()
})
it('字符串原样保留(含空格)', () => {
expect(coerceArgument(field('string'), ' notes/a.md ')).toBe(' notes/a.md ')
})
})
describe('missingRequiredFields', () => {
it('列出未填的必填字段', () => {
const missing = missingRequiredFields(schema, initialArguments(schema))
expect(missing.map((f) => f.key)).toEqual(['path', 'mode'])
})
it('空白字符串算没填', () => {
const missing = missingRequiredFields(schema, { path: ' ', mode: 'fast' })
expect(missing.map((f) => f.key)).toEqual(['path'])
})
it('布尔 false 是合法值,不算缺失', () => {
const boolSchema = command({
type: 'object',
properties: { flag: { type: 'boolean' } },
required: ['flag'],
})
expect(missingRequiredFields(boolSchema, { flag: false })).toEqual([])
})
it('全部填好时返回空数组', () => {
expect(missingRequiredFields(schema, { path: 'a.md', mode: 'fast' })).toEqual([])
})
})
describe('cleanArguments', () => {
it('丢掉 undefined 的键,保留 false / 0 / 空串', () => {
const cleaned = cleanArguments({ a: undefined, b: false, c: 0, d: '', e: null })
expect(cleaned).toEqual({ b: false, c: 0, d: '', e: null })
expect('a' in cleaned).toBe(false)
})
})
describe('applyCommandEffect', () => {
function handlers() {
return { navigate: vi.fn(), refresh: vi.fn(), notify: vi.fn() }
}
it('navigate 真的触发跳转,而不是只提示一句话', async () => {
// 回归:之前只把 effect 拼成描述文本显示,命令等于没生效。
const h = handlers()
await applyCommandEffect({ type: 'navigate', payload: { route: 'workspace' } }, h)
expect(h.navigate).toHaveBeenCalledWith('/workspace')
expect(h.notify).not.toHaveBeenCalled()
})
it('每个白名单路由都能解析出路径', async () => {
for (const route of Object.keys(EFFECT_ROUTES)) {
const h = handlers()
await applyCommandEffect(
{ type: 'navigate', payload: { route } } as PluginCommandEffect,
h,
)
expect(h.navigate).toHaveBeenCalledWith(EFFECT_ROUTES[route])
}
})
it('未知路由只提示不跳转,避免 router.push(undefined)', async () => {
const h = handlers()
await applyCommandEffect(
{ type: 'navigate', payload: { route: 'nope' } } as unknown as PluginCommandEffect,
h,
)
expect(h.navigate).not.toHaveBeenCalled()
expect(h.notify.mock.calls[0][0]).toContain('nope')
})
it('refresh 真的触发对应 scope 的刷新', async () => {
const h = handlers()
await applyCommandEffect({ type: 'refresh', payload: { scope: 'workspace' } }, h)
expect(h.refresh).toHaveBeenCalledWith('workspace')
})
it('等待异步 refresh 完成后才返回', async () => {
const h = handlers()
let done = false
h.refresh.mockImplementation(async () => {
await Promise.resolve()
done = true
})
await applyCommandEffect({ type: 'refresh', payload: { scope: 'commands' } }, h)
expect(done).toBe(true)
})
it('notification 原样透出插件消息', async () => {
const h = handlers()
await applyCommandEffect(
{ type: 'notification', payload: { level: 'info', message: '索引已重建' } },
h,
)
expect(h.notify).toHaveBeenCalledWith('索引已重建')
})
it('job 提示任务 id', async () => {
const h = handlers()
await applyCommandEffect({ type: 'job', payload: { job_id: 'job_7' } }, h)
expect(h.notify.mock.calls[0][0]).toContain('job_7')
})
it('none 或未知 type 按「已完成」处理,不猜测语义', async () => {
const h = handlers()
await applyCommandEffect({ type: 'none', payload: {} }, h)
expect(h.notify).toHaveBeenCalledWith('命令执行完成。')
expect(h.navigate).not.toHaveBeenCalled()
expect(h.refresh).not.toHaveBeenCalled()
})
})
+152
View File
@@ -0,0 +1,152 @@
import type { PluginCommand, PluginCommandEffect } from '@/contracts'
/** 命令参数的 JSON Schema 字段定义(后端用 Draft 2020-12 校验)。 */
export interface CommandField {
key: string
title: string
type: string
required: boolean
enum?: string[]
default?: unknown
description?: string
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
/**
* 把命令的 parametersobject schema)摊平成表单字段。
*
* 后端只接受 type=object 的 schemacontributions.py 里显式拒绝其他形态),
* 所以这里只处理 properties + required 两个键,嵌套对象按文本输入兜底。
*/
export function commandFields(command: PluginCommand): CommandField[] {
const schema = asRecord(command.parameters)
const properties = asRecord(schema.properties)
const requiredKeys = Array.isArray(schema.required) ? schema.required.map(String) : []
return Object.entries(properties).map(([key, rawDefinition]) => {
const definition = asRecord(rawDefinition)
return {
key,
title: typeof definition.title === 'string' && definition.title ? definition.title : key,
type: typeof definition.type === 'string' ? definition.type : 'string',
required: requiredKeys.includes(key),
enum: Array.isArray(definition.enum) ? definition.enum.map(String) : undefined,
default: definition.default,
description: typeof definition.description === 'string' ? definition.description : undefined,
}
})
}
/**
* 表单初始值。
*
* 布尔字段必须显式给 false —— 下拉框默认显示「否」,如果参数对象里
* 没有这个键,用户看到的和实际提交的就不一致。
*/
export function initialArguments(command: PluginCommand): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const field of commandFields(command)) {
if (field.default !== undefined) result[field.key] = field.default
else if (field.type === 'boolean') result[field.key] = false
}
return result
}
/** 按字段类型把输入框的字符串转成 schema 期望的类型。 */
export function coerceArgument(field: CommandField, raw: string): unknown {
if (field.type === 'boolean') return raw === 'true'
if (field.type === 'number' || field.type === 'integer') {
if (raw.trim() === '') return undefined
const parsed = Number(raw)
return Number.isNaN(parsed) ? undefined : parsed
}
return raw
}
function isBlank(value: unknown): boolean {
if (value === undefined || value === null) return true
return typeof value === 'string' && value.trim() === ''
}
/**
* 找出还没填的必填字段。
*
* 后端会用 JSON Schema 再校验一次,这里做前置检查只为了别让用户
* 提交一次才知道少填了什么。布尔的 false 是合法值,不算缺失。
*/
export function missingRequiredFields(
command: PluginCommand,
args: Record<string, unknown>,
): CommandField[] {
return commandFields(command).filter((field) => field.required && isBlank(args[field.key]))
}
/** undefined 的键不该出现在请求体里。 */
export function cleanArguments(args: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(args)) {
if (value !== undefined) result[key] = value
}
return result
}
/** navigate effect 的路由白名单,与 router/index.ts 的路径一一对应。 */
export const EFFECT_ROUTES: Record<string, string> = {
'vault-entry': '/',
workspace: '/workspace',
search: '/search',
chat: '/chat',
agent: '/agent/runs',
tasks: '/tasks',
skills: '/extensions/skills',
plugins: '/extensions/plugins',
themes: '/themes',
settings: '/settings',
}
export interface EffectHandlers {
navigate: (path: string) => Promise<unknown> | unknown
refresh: (scope: 'workspace' | 'commands' | 'settings' | 'plugins') => Promise<unknown> | unknown
notify: (message: string) => void
}
/**
* 执行命令返回的 effect。
*
* navigate / refresh 必须真的发生 —— 之前这里只是把 effect 拼成一句话
* 显示给用户,命令等于没生效。未知 type 一律按「已完成」处理,
* 不猜测语义。
*/
export async function applyCommandEffect(
effect: PluginCommandEffect,
handlers: EffectHandlers,
): Promise<void> {
switch (effect.type) {
case 'notification':
handlers.notify(effect.payload.message)
return
case 'navigate': {
const path = EFFECT_ROUTES[effect.payload.route]
if (!path) {
handlers.notify(`命令请求跳转到未知路由「${effect.payload.route}」,已忽略。`)
return
}
await handlers.navigate(path)
return
}
case 'refresh':
await handlers.refresh(effect.payload.scope)
handlers.notify('相关数据已刷新。')
return
case 'job':
handlers.notify(`已创建后台任务:${effect.payload.job_id}`)
return
default:
handlers.notify('命令执行完成。')
}
}
Binary file not shown.
@@ -0,0 +1,426 @@
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 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
}
/**
* 主题包是单文件文本格式:YAML 清单 + 一行 `---` + 主题 CSS。
*
* theme_id: my-theme
* name: My Theme
* ...
* ---
* [data-theme="my-theme"] { --color-... }
*
* 浏览器端没有解压能力,所以不支持 ZIP —— 与其把二进制当文本解析出
* 一堆乱码再报「清单无效」,不如直接告诉用户格式不支持。
*/
export function parseThemePackage(packageData: string): { manifestText: string; css: string } {
if (looksLikeZip(packageData)) {
throw new Error(
'THEME_PACKAGE_UNSUPPORTED_FORMAT: 暂不支持 ZIP 主题包,请提供「YAML 清单 + --- + CSS」的单文件主题。',
)
}
const lines = packageData.split(/\r?\n/)
const separatorIndex = lines.findIndex((line) => line.trim() === '---')
if (separatorIndex < 0) {
throw new Error(
'THEME_PACKAGE_INVALID: 主题包缺少 `---` 分隔行,无法区分清单与 CSS。',
)
}
const manifestText = lines.slice(0, separatorIndex).join('\n')
const css = lines.slice(separatorIndex + 1).join('\n').trim()
if (!css) {
throw new Error('THEME_CSS_INVALID: 主题包内没有 CSS 内容。')
}
return { manifestText, css }
}
/** ZIP 的魔数是 PK\x03\x04base64 形式(readAsDataURL)开头是 UEsDB。 */
function looksLikeZip(data: string): boolean {
if (data.startsWith('PK')) return true
return /^data:.*;base64,UEsDB/.test(data) || data.startsWith('UEsDB')
}
export async function selectThemePackage(): Promise<string | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
// 只接受能在浏览器里解析的单文件主题;ZIP 需要 Host 端解压,暂不支持。
input.accept = '.yaml,.yml,.theme'
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)
reader.readAsText(file)
}
input.oncancel = () => resolve(null)
input.click()
})
}
export async function inspectThemePackage(packageData: string): Promise<ThemePackageInspection> {
const package_id = `theme_pkg_${Date.now()}`
try {
const { manifestText, css } = parseThemePackage(packageData)
const manifest = inspectYamlContent(manifestText)
// CSS 的安全校验放在这里,不合规的包在「预览」阶段就该被拒,
// 而不是等到用户点安装。
const warnings = validateCssSafety(css)
if (!css.includes(`[data-theme="${manifest.theme_id}"]`)) {
warnings.push(`CSS 未包含 [data-theme="${manifest.theme_id}"] 选择器,主题可能不会生效。`)
}
return {
package_id,
manifest,
preview_url: '',
warnings,
compatible: true,
css,
}
} 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,
css: '',
}
}
}
export async function installTheme(
manifest: ThemeManifest,
cssContent: string,
): Promise<InstalledTheme> {
// validateCssSafety 会对 @import / expression() / javascript: 抛错,
// 必须在 applyThemeCss 之前调用 —— 未校验的 CSS 一律不许进入页面。
const warnings = validateCssSafety(cssContent)
if (warnings.length > 0) {
console.warn('[theme] CSS validation warnings:', warnings)
}
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[]> {
return loadStoredThemes()
}
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) {
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
// Validate before changing the current page. Only the selected theme owns a style node.
if (css) validateCssSafety(css)
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
if (themeId && css) applyThemeCss(themeId, css)
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)
}
+191
View File
@@ -0,0 +1,191 @@
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 }
}
/**
* backend/app/agent/runtime.py
* ModelCallStarted ModelCallCompleted Usage ToolCall ToolResult
* asyncio.gather +
* Semaphore id
*/
describe('buildTraceNodes', () => {
it('工具事件按 parent_model_call_id 归属,即使出现在 ModelCallCompleted 之后', () => {
const nodes = buildTraceNodes([
event('RunStarted'),
event('ModelCallStarted', { model_call_id: 'mc-1', model: 'mock-1', provider_id: 'mock' }),
event('ModelCallCompleted', { model_call_id: 'mc-1', duration_ms: 1200, finish_reason: 'tool_calls' }),
event('Usage', { token_usage: 320 }),
event('ToolCall', { tool_call_id: 'tc-1', name: 'read_note', parent_model_call_id: 'mc-1' }),
event('ToolResult', { tool_call_id: 'tc-1', name: 'read_note', success: true, duration_ms: 40, parent_model_call_id: 'mc-1' }),
event('RunCompleted'),
])
// 顶层:运行开始、模型调用、Usage、运行完成。工具挂在模型调用下面。
expect(nodes.map((n) => n.type)).toEqual(['run', 'model_call', 'usage', 'complete'])
const modelCall = nodes[1]
expect(modelCall.status).toBe('completed')
expect(modelCall.duration_ms).toBe(1200)
expect(modelCall.children.map((c) => c.type)).toEqual(['tool_call'])
})
it('ToolResult 回填对应 ToolCall 的状态,结束后不再显示 running', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-2' }),
event('ModelCallCompleted', { model_call_id: 'mc-2' }),
event('ToolCall', { tool_call_id: 'tc-2', name: 'read_note', parent_model_call_id: 'mc-2' }),
event('ToolResult', { tool_call_id: 'tc-2', name: 'read_note', success: true, duration_ms: 55, parent_model_call_id: 'mc-2' }),
])
const toolCall = nodes[0].children[0]
expect(toolCall.type).toBe('tool_call')
expect(toolCall.status).toBe('completed')
expect(toolCall.duration_ms).toBe(55)
// 结果数据合并进调用节点,展开详情时能看到 output。
expect((toolCall.data.result as Record<string, unknown>).success).toBe(true)
})
it('工具失败时把 ToolCall 标记为 error 并带上 error_code', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-3' }),
event('ToolCall', { tool_call_id: 'tc-3', name: 'write_note', parent_model_call_id: 'mc-3' }),
event('ToolResult', { tool_call_id: 'tc-3', name: 'write_note', success: false, error_code: 'TOOL_DENIED', parent_model_call_id: 'mc-3' }),
])
const toolCall = nodes[0].children[0]
expect(toolCall.status).toBe('error')
expect(toolCall.subtitle).toContain('TOOL_DENIED')
})
it('并发工具交错到达时各自归属到正确的模型调用', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-a' }),
event('ModelCallCompleted', { model_call_id: 'mc-a' }),
event('ToolCall', { tool_call_id: 'a1', name: 'toolA1', parent_model_call_id: 'mc-a' }),
event('ToolCall', { tool_call_id: 'a2', name: 'toolA2', parent_model_call_id: 'mc-a' }),
event('ModelCallStarted', { model_call_id: 'mc-b' }),
event('ModelCallCompleted', { model_call_id: 'mc-b' }),
event('ToolCall', { tool_call_id: 'b1', name: 'toolB1', parent_model_call_id: 'mc-b' }),
// 第一个模型调用的工具结果比第二轮的工具调用还晚到
event('ToolResult', { tool_call_id: 'a2', name: 'toolA2', success: true, parent_model_call_id: 'mc-a' }),
event('ToolResult', { tool_call_id: 'a1', name: 'toolA1', success: true, parent_model_call_id: 'mc-a' }),
event('ToolResult', { tool_call_id: 'b1', name: 'toolB1', success: true, parent_model_call_id: 'mc-b' }),
])
const [callA, callB] = nodes.filter((n) => n.type === 'model_call')
expect(callA.children.map((c) => c.title)).toEqual(['工具调用:toolA1', '工具调用:toolA2'])
expect(callB.children.map((c) => c.title)).toEqual(['工具调用:toolB1'])
expect(callA.children.every((c) => c.status === 'completed')).toBe(true)
})
it('模型调用失败时标记为 error 并附带 error_code', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-4', model: 'mock-1' }),
event('ModelCallFailed', { model_call_id: 'mc-4', error_code: 'PROVIDER_TIMEOUT', duration_ms: 900 }),
])
expect(nodes).toHaveLength(1)
expect(nodes[0].status).toBe('error')
expect(nodes[0].duration_ms).toBe(900)
expect(nodes[0].subtitle).toContain('PROVIDER_TIMEOUT')
})
it('PermissionRequired 不带父 id,留在顶层', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-5' }),
event('PermissionRequired', { request_id: 'r1', permission: 'notes.write' }),
])
expect(nodes.map((n) => n.type)).toEqual(['model_call', 'permission'])
expect(nodes[1].status).toBe('pending')
})
it('运行级事件始终留在顶层,不会被模型调用吞掉', () => {
const nodes = buildTraceNodes([
event('ModelCallStarted', { model_call_id: 'mc-6' }),
event('RunFailed', { error_code: 'RUN_TIMEOUT' }),
])
expect(nodes.map((n) => n.type)).toEqual(['model_call', 'error'])
})
it('SSE 断点恢复只拿到后半段时,孤立事件退回顶层而不是被丢弃', () => {
// 没有 ModelCallStarted,也没有对应的 ToolCall
const nodes = buildTraceNodes([
event('ModelCallCompleted', { model_call_id: 'mc-lost', duration_ms: 10 }),
event('ToolResult', { tool_call_id: 'tc-lost', name: 'read_note', success: false, error_code: 'TOOL_FAILED' }),
])
expect(nodes).toHaveLength(2)
expect(nodes[0].type).toBe('model_call')
// 落单的失败结果不能显示成 completed
expect(nodes[1].status).toBe('error')
})
it('Usage 副标题读后端真实字段 token_usage', () => {
const nodes = buildTraceNodes([event('Usage', { token_usage: 1234 })])
expect(nodes[0].subtitle).toBe('1234 tokens')
})
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)
})
})
+304
View File
@@ -0,0 +1,304 @@
import type { AgentEvent, TraceNode, TraceNodeType } from '@/contracts'
/**
*
*
* id
* ModelCallStarted ModelCallCompleted Usage ToolCall/ToolResult
*
* runtime.py asyncio.gather + Semaphore
* data.parent_model_call_id
* ToolResult data.tool_call_id ToolCall
*/
export function buildTraceNodes(events: AgentEvent[]): TraceNode[] {
const roots: TraceNode[] = []
/** model_call_id -> 模型调用节点 */
const modelCalls = new Map<string, TraceNode>()
/** tool_call_id -> 工具调用节点,供 ToolResult 回填状态 */
const toolCalls = new Map<string, TraceNode>()
for (const event of events) {
const node: TraceNode = {
id: `seq-${event.sequence}`,
sequence: event.sequence,
type: mapEventType(event.event),
title: getNodeTitle(event),
subtitle: getNodeSubtitle(event),
status: getNodeStatus(event),
data: event.data,
timestamp: event.timestamp,
children: [],
}
const modelCallId = asId(event.data.model_call_id)
const parentModelCallId = asId(event.data.parent_model_call_id)
const toolCallId = asId(event.data.tool_call_id)
switch (event.event) {
case 'ModelCallStarted': {
if (modelCallId) modelCalls.set(modelCallId, node)
roots.push(node)
continue
}
// 完成/失败事件不单独成节点,只更新对应模型调用的状态。
case 'ModelCallCompleted':
case 'ModelCallFailed': {
const target = modelCallId ? modelCalls.get(modelCallId) : undefined
if (!target) {
// 找不到配对的 Started(例如 SSE 断点恢复后只拿到后半段),保留为顶层节点。
roots.push(node)
continue
}
target.status = event.event === 'ModelCallCompleted' ? 'completed' : 'error'
const duration = asNumber(event.data.duration_ms)
if (duration != null) target.duration_ms = duration
const extra = event.event === 'ModelCallCompleted'
? asText(event.data.finish_reason)
: asText(event.data.error_code)
if (extra) target.subtitle = target.subtitle ? `${target.subtitle} · ${extra}` : extra
continue
}
// ToolResult 只回填对应 ToolCall,避免工具结束后仍显示 running。
case 'ToolResult': {
const target = toolCallId ? toolCalls.get(toolCallId) : undefined
if (!target) {
attach(node, parentModelCallId, modelCalls, roots)
continue
}
target.status = event.data.success === false ? 'error' : 'completed'
const duration = asNumber(event.data.duration_ms)
if (duration != null) target.duration_ms = duration
const detail = event.data.success === false
? asText(event.data.error_code) ?? '失败'
: undefined
if (detail) target.subtitle = target.subtitle ? `${target.subtitle} · ${detail}` : detail
// 结果数据合并到调用节点,展开详情时才能看到 output。
target.data = { ...target.data, result: event.data }
continue
}
case 'ToolCall': {
if (toolCallId) toolCalls.set(toolCallId, node)
attach(node, parentModelCallId, modelCalls, roots)
continue
}
default: {
attach(node, parentModelCallId, modelCalls, roots)
continue
}
}
}
return roots
}
/** 有已知父模型调用就挂进去,否则留在顶层。 */
function attach(
node: TraceNode,
parentModelCallId: string | null,
modelCalls: Map<string, TraceNode>,
roots: TraceNode[],
) {
const parent = parentModelCallId ? modelCalls.get(parentModelCallId) : undefined
if (parent) {
node.parent_id = parent.id
parent.children.push(node)
return
}
roots.push(node)
}
function asId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}
function asText(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined
}
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': {
// 后端发的是累计 token_usageruntime.py),其余字段仅作兼容回退。
const usage = asNumber(data.token_usage) ?? asNumber(data.total_tokens)
if (usage != null) return `${usage} tokens`
const input = asNumber(data.input_tokens)
const output = asNumber(data.output_tokens)
if (input == null && output == null) return undefined
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 'ToolResult':
// 只在 ToolResult 没配上 ToolCall 时(SSE 断点恢复)才成为独立节点,
// 那时也要按 success 显示,不能一律算成功。
return event.data.success === false ? 'error' : 'completed'
case 'RunCompleted':
case 'RunCancelled':
case 'ModelCallCompleted':
case 'Usage':
case 'PermissionResolved':
return 'completed'
case 'ToolCall':
// 后端的 ToolCall 事件不带 status,起始一律 running
// 由后到的 ToolResult 回填最终状态。
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()]
}
+134 -2
View File
@@ -1,8 +1,42 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import type { InstalledTheme } from '@/contracts'
import { useThemeStore } from './theme'
import * as themePkg from '@/services/themePackageService'
vi.mock('@/services/themePackageService', () => ({
listInstalledThemes: vi.fn(async () => []),
inspectThemePackage: vi.fn(),
installTheme: vi.fn(),
uninstallTheme: vi.fn(),
installCommunityTheme: vi.fn(),
setActiveCustomTheme: vi.fn(),
}))
const listInstalledThemes = vi.mocked(themePkg.listInstalledThemes)
function customTheme(themeId: string, isDark = false): InstalledTheme {
return {
theme_id: themeId,
name: themeId,
version: '1.0.0',
author: '社区',
is_dark: isDark,
builtin: false,
enabled: true,
manifest: {
theme_id: themeId,
name: themeId,
version: '1.0.0',
author: '社区',
min_app_version: '0.1.0',
is_dark: isDark,
css_entry: 'theme.css',
},
}
}
beforeEach(() => {
localStorage.clear()
@@ -13,6 +47,8 @@ beforeEach(() => {
configurable: true,
value: () => ({ matches: false }),
})
listInstalledThemes.mockReset()
listInstalledThemes.mockResolvedValue([])
})
describe('代码块主题偏好', () => {
@@ -38,10 +74,106 @@ describe('代码块主题偏好', () => {
it('恢复持久化的代码块主题偏好', async () => {
localStorage.setItem('editor-appearance', JSON.stringify({ codeBlockTheme: 'github-dark' }))
const store = useThemeStore()
store.initTheme()
await store.initTheme()
await nextTick()
expect(store.codeBlockTheme).toBe('github-dark')
expect(document.documentElement.dataset.codeTheme).toBe('github-dark')
})
})
describe('initTheme 恢复已保存主题', () => {
it('等自定义主题加载完成后再恢复,不会停在没有 data-theme 的裸状态', async () => {
// 回归:之前这里是 `void loadCustomThemes()` 没有 await
// applyTheme('ocean') 在主题列表到达前找不到主题直接 return,
// 页面上一个 data-theme 都没有。
localStorage.setItem('theme', 'ocean')
listInstalledThemes.mockResolvedValue([customTheme('ocean', true)])
const store = useThemeStore()
await store.initTheme()
expect(document.documentElement.getAttribute('data-theme')).toBe('ocean')
expect(store.currentThemeId).toBe('ocean')
expect(store.themeLoadWarning).toBeNull()
})
it('首屏先同步落内置主题兜底,且不覆盖保存的自定义主题 id', async () => {
localStorage.setItem('theme', 'ocean')
let resolveList: (themes: InstalledTheme[]) => void = () => {}
listInstalledThemes.mockReturnValue(
new Promise<InstalledTheme[]>((resolve) => { resolveList = resolve }),
)
const store = useThemeStore()
const pending = store.initTheme()
// 接口还没回来:页面已经有兜底主题,不是裸的
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
// 兜底不能把用户存的主题 id 冲掉,否则刷新后自定义主题就丢了
expect(localStorage.getItem('theme')).toBe('ocean')
resolveList([customTheme('ocean', true)])
await pending
expect(document.documentElement.getAttribute('data-theme')).toBe('ocean')
})
it('保存的主题已被卸载时回退到默认主题并给出提示', async () => {
localStorage.setItem('theme', 'removed-theme')
listInstalledThemes.mockResolvedValue([])
const store = useThemeStore()
await store.initTheme()
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
expect(store.currentThemeId).toBe('light')
expect(store.themeLoadWarning).toContain('removed-theme')
// 失效记录要清掉,避免每次启动都报一遍
expect(localStorage.getItem('theme')).toBe('light')
})
it('主题列表加载失败时提示用户,而不是静默只剩内置主题', async () => {
localStorage.setItem('theme', 'dark')
listInstalledThemes.mockRejectedValue(new Error('网络不可用'))
const store = useThemeStore()
await store.initTheme()
expect(store.themeLoadWarning).toBe('自定义主题加载失败:网络不可用')
// 内置主题仍然要正常恢复
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
})
it('没有保存过主题时按系统偏好选择', async () => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: () => ({ matches: true }),
})
const store = useThemeStore()
await store.initTheme()
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
expect(localStorage.getItem('theme')).toBe('dark')
})
})
describe('applyTheme 返回值', () => {
it('主题不存在时返回 false 且不改动 data-theme', () => {
const store = useThemeStore()
store.applyTheme('light')
expect(store.applyTheme('not-installed')).toBe(false)
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
expect(store.currentThemeId).toBe('light')
})
it('persist: false 时不写 localStorage', () => {
const store = useThemeStore()
expect(store.applyTheme('sepia', { persist: false })).toBe(true)
expect(document.documentElement.getAttribute('data-theme')).toBe('sepia')
expect(localStorage.getItem('theme')).toBeNull()
})
})
+179 -20
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { ThemeConfig } from '@/contracts'
import type { ThemeConfig, ThemeManifest, InstalledTheme, ThemePackageInspection } from '@/contracts'
import * as themePkg from '@/services/themePackageService'
import { t } from '@/i18n'
const builtinThemes = (): ThemeConfig[] => [
@@ -15,42 +16,99 @@ function isCodeBlockThemePreference(value: unknown): value is CodeBlockThemePref
return value === 'auto' || value === 'github-light' || value === 'github-dark'
}
const builtinToInstalled = (t: ThemeConfig): InstalledTheme => ({
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
description: t.description,
is_dark: t.is_dark,
builtin: true,
enabled: true,
manifest: {
theme_id: t.theme_id,
name: t.name,
version: t.version,
author: 'NotesAgent 团队',
description: t.description,
min_app_version: '0.1.0',
is_dark: t.is_dark,
css_entry: 'builtin',
},
code_theme: t.code_theme,
})
export const useThemeStore = defineStore('theme', () => {
const themes = computed<ThemeConfig[]>(builtinThemes)
const themes = computed<ThemeConfig[]>(() => [...builtinThemes(), ...installedCustomThemes.value.map(theme => ({ ...theme, description: theme.description ?? '' }))])
const installedCustomThemes = ref<InstalledTheme[]>([])
const currentThemeId = ref<string>('light')
const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7)
const codeBlockTheme = ref<CodeBlockThemePreference>('auto')
const isImporting = ref(false)
const importError = ref<string | null>(null)
// 主题恢复阶段的提示(保存的主题已卸载、主题列表加载失败等),与导入错误分开。
const themeLoadWarning = ref<string | null>(null)
const pendingInspection = ref<ThemePackageInspection | null>(null)
let appearanceHydrated = false
const allThemes = computed<InstalledTheme[]>(() => [
...builtinThemes().map(builtinToInstalled),
...installedCustomThemes.value,
])
const currentTheme = computed(() =>
themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0]
allThemes.value.find((t) => t.theme_id === currentThemeId.value) || allThemes.value[0]
)
const isDark = computed(() => currentTheme.value?.is_dark || false)
const resolvedCodeBlockTheme = computed<'github-light' | 'github-dark'>(() => {
if (codeBlockTheme.value !== 'auto') return codeBlockTheme.value
return currentTheme.value?.code_theme ?? (isDark.value ? 'github-dark' : 'github-light')
})
function applyTheme(themeId: string) {
const theme = themes.value.find((t) => t.theme_id === themeId)
if (!theme) return
/** 应用主题;返回 false 表示该主题当前不存在(未安装或还没加载完)。 */
function applyTheme(themeId: string, options: { persist?: boolean } = {}): boolean {
const theme = allThemes.value.find((t) => t.theme_id === themeId)
if (!theme) return false
themePkg.setActiveCustomTheme(theme.builtin ? null : themeId)
currentThemeId.value = themeId
const root = document.documentElement
if (theme.is_dark) {
root.setAttribute('data-theme', 'dark')
} else if (themeId === 'sepia') {
root.setAttribute('data-theme', 'sepia')
if (theme.builtin) {
if (theme.is_dark) {
root.setAttribute('data-theme', 'dark')
} else if (themeId === 'sepia') {
root.setAttribute('data-theme', 'sepia')
} else {
root.setAttribute('data-theme', 'light')
}
} else {
root.setAttribute('data-theme', 'light')
root.setAttribute('data-theme', themeId)
}
localStorage.setItem('theme', themeId)
if (options.persist !== false) localStorage.setItem('theme', themeId)
return true
}
function initTheme() {
// 先恢复外观再开放 watch 持久化,避免 immediate watcher 覆盖本地设置。
function isBuiltinThemeId(themeId: string): boolean {
return builtinThemes().some((t) => t.theme_id === themeId)
}
function systemThemeId(): string {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
/**
*
*
* listInstalledThemes allThemes
* loadCustomThemes applyTheme
* return data-theme
* localStorage
* id
*/
async function initTheme(): Promise<void> {
const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) {
try {
@@ -61,15 +119,38 @@ export const useThemeStore = defineStore('theme', () => {
if (isCodeBlockThemePreference(value.codeBlockTheme)) codeBlockTheme.value = value.codeBlockTheme
} catch { localStorage.removeItem('editor-appearance') }
}
const saved = localStorage.getItem('theme')
appearanceHydrated = true
persistAppearance()
if (saved && themes.value.find((t) => t.theme_id === saved)) {
applyTheme(saved)
const saved = localStorage.getItem('theme')
const fallback = systemThemeId()
applyTheme(saved && isBuiltinThemeId(saved) ? saved : fallback, { persist: false })
await loadCustomThemes()
if (!saved) {
applyTheme(fallback)
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
applyTheme(prefersDark ? 'dark' : 'light')
if (applyTheme(saved)) return
// 保存的主题已被卸载,或主题列表加载失败:回退并清掉失效记录。
themeLoadWarning.value = `主题「${saved}」已不可用,已回退到默认主题。`
localStorage.removeItem('theme')
applyTheme(fallback)
}
async function loadCustomThemes() {
try {
const list = await themePkg.listInstalledThemes()
installedCustomThemes.value = list
themeLoadWarning.value = null
} catch (error) {
// 只保留内置主题,但要让用户知道自定义主题这次没加载上。
themeLoadWarning.value = error instanceof Error
? `自定义主题加载失败:${error.message}`
: '自定义主题加载失败。'
}
}
function toggleTheme() {
@@ -91,8 +172,74 @@ export const useThemeStore = defineStore('theme', () => {
codeBlockTheme: codeBlockTheme.value,
}))
async function inspectThemePackage(packageData: string): Promise<ThemePackageInspection> {
isImporting.value = true
importError.value = null
try {
const result = await themePkg.inspectThemePackage(packageData)
pendingInspection.value = result
if (!result.compatible) {
importError.value = result.warnings[0] ?? '主题包不兼容'
}
return result
} catch (error) {
importError.value = error instanceof Error ? error.message : '导入失败'
throw error
} finally {
isImporting.value = false
}
}
async function installThemeFromInspection(manifest: ThemeManifest, cssContent: string) {
isImporting.value = true
importError.value = null
try {
const installed = await themePkg.installTheme(manifest, cssContent)
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (idx >= 0) installedCustomThemes.value[idx] = installed
else installedCustomThemes.value.push(installed)
if (currentThemeId.value === installed.theme_id) applyTheme(installed.theme_id)
pendingInspection.value = null
return installed
} catch (error) {
importError.value = error instanceof Error ? error.message : '安装失败'
throw error
} finally {
isImporting.value = false
}
}
async function uninstallTheme(themeId: string) {
await themePkg.uninstallTheme(themeId)
installedCustomThemes.value = installedCustomThemes.value.filter((t) => t.theme_id !== themeId)
if (currentThemeId.value === themeId) {
applyTheme('light')
}
}
async function installCommunityTheme(themeId: string) {
isImporting.value = true
importError.value = null
try {
const installed = await themePkg.installCommunityTheme(themeId)
const idx = installedCustomThemes.value.findIndex((t) => t.theme_id === installed.theme_id)
if (idx >= 0) installedCustomThemes.value[idx] = installed
else installedCustomThemes.value.push(installed)
if (currentThemeId.value === installed.theme_id) applyTheme(installed.theme_id)
return installed
} catch (error) {
importError.value = error instanceof Error ? error.message : '安装失败'
throw error
} finally {
isImporting.value = false
}
}
function isThemeInstalled(themeId: string): boolean {
return themes.value.some((t) => t.theme_id === themeId)
}
watch(resolvedCodeBlockTheme, (theme) => {
// CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。
document.documentElement.setAttribute('data-code-theme', theme)
}, { immediate: true })
@@ -117,6 +264,7 @@ export const useThemeStore = defineStore('theme', () => {
return {
themes,
installedCustomThemes,
currentThemeId,
currentTheme,
isDark,
@@ -125,9 +273,20 @@ export const useThemeStore = defineStore('theme', () => {
lineHeight,
codeBlockTheme,
resolvedCodeBlockTheme,
isImporting,
importError,
themeLoadWarning,
pendingInspection,
allThemes,
applyTheme,
initTheme,
toggleTheme,
resetToDefault,
loadCustomThemes,
inspectThemePackage,
installThemeFromInspection,
uninstallTheme,
installCommunityTheme,
isThemeInstalled,
}
})
@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useThemeStore } from './theme'
import { installTheme } from '@/services/themePackageService'
import type { ThemeManifest } from '@/contracts'
const manifest = (id: string): ThemeManifest => ({ theme_id: id, name: id, version: '1.0.0', author: 'test', min_app_version: '0.1.0', is_dark: false, css_entry: 'theme.css' })
beforeEach(() => {
localStorage.clear()
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(el => el.remove())
setActivePinia(createPinia())
})
it('does not apply installed CSS until selected and removes it when returning to a builtin theme', async () => {
const store = useThemeStore()
store.applyTheme('light')
const initialColor = getComputedStyle(document.body).color
await store.installThemeFromInspection(manifest('first'), 'body { color: rgb(1, 2, 3) !important; }')
await store.loadCustomThemes()
expect(getComputedStyle(document.body).color).toBe(initialColor)
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
store.applyTheme('first')
expect(getComputedStyle(document.body).color).toBe('rgb(1, 2, 3)')
store.applyTheme('dark')
expect(getComputedStyle(document.body).color).toBe(initialColor)
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
})
it('keeps only the selected custom theme mounted, including after a list reload', async () => {
const store = useThemeStore()
await installTheme(manifest('first'), 'body { color: rgb(1, 2, 3) !important; }')
await installTheme(manifest('second'), 'body { background-color: rgb(4, 5, 6) !important; }')
await store.loadCustomThemes()
store.applyTheme('first')
await store.loadCustomThemes()
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(1)
store.applyTheme('second')
expect(document.getElementById('theme-style-first')).toBeNull()
expect(document.getElementById('theme-style-second')).not.toBeNull()
await store.uninstallTheme('second')
expect(store.currentThemeId).toBe('light')
expect(document.head.querySelectorAll('style[id^="theme-style-"]')).toHaveLength(0)
})
it('restores only the saved custom theme on startup', async () => {
await installTheme(manifest('first'), 'body { color: rgb(1, 2, 3); }')
await installTheme(manifest('second'), 'body { color: rgb(4, 5, 6); }')
localStorage.setItem('theme', 'first')
const store = useThemeStore()
await store.initTheme()
expect(store.currentThemeId).toBe('first')
expect(document.getElementById('theme-style-first')).not.toBeNull()
expect(document.getElementById('theme-style-second')).toBeNull()
})
+7
View File
@@ -62,6 +62,12 @@ export const useWorkspaceStore = defineStore('workspace', () => {
recentVaults.value = await workspaceService.getRecentVaults()
}
/** 重新拉取文件树。插件命令返回 refresh:workspace 时需要。 */
async function refreshFileTree() {
if (!hasVault.value) return
fileTree.value = await workspaceService.getFileTree()
}
async function openVault(path: string) {
isLoading.value = true
try {
@@ -161,6 +167,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
closeFile,
setActiveFile,
loadRecentVaults,
refreshFileTree,
openVault,
createVault,
addFileToTree,
+8
View File
@@ -10,4 +10,12 @@ describe('Shiki GitHub 双主题', () => {
expect(html).toContain('--shiki-light')
expect(html).toContain('--shiki-dark')
})
it('按需加载原先未支持的语言,并解析 Shiki 别名', async () => {
const source = 'fn main() { let answer = 42; }'
const [rust, alias] = await Promise.all([highlightCode(source, 'rust'), highlightCode(source, 'rs')])
expect(alias).toBe(rust)
expect(rust).toContain('github-dark')
expect(new Set([...rust.matchAll(/--shiki-light:([^;" ]+)/g)].map(match => match[1])).size).toBeGreaterThan(1)
})
})
+75 -21
View File
@@ -1,55 +1,109 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import css from '@shikijs/langs/css'
import html from '@shikijs/langs/html'
import javascript from '@shikijs/langs/javascript'
import json from '@shikijs/langs/json'
import markdown from '@shikijs/langs/markdown'
import python from '@shikijs/langs/python'
import shell from '@shikijs/langs/shellscript'
import sql from '@shikijs/langs/sql'
import typescript from '@shikijs/langs/typescript'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService'
marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({
themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
engine: createJavaScriptRegexEngine(),
langs: [],
engine: createOnigurumaEngine(import('shiki/wasm')),
})
const languageAliases: Record<string, string> = {
bash: 'shell', js: 'javascript', md: 'markdown', plaintext: 'text', py: 'python', sh: 'shell', ts: 'typescript',
const languageAliases = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.id] as const),
))
const languageLoads = new Map<string, Promise<void>>()
const languageLoaders = new Map(bundledLanguagesInfo.map(info => [info.id, info.import]))
async function loadCodeLanguage(requestedLanguage: string) {
const shiki = await highlighter
const language = languageAliases.get(requestedLanguage.toLowerCase())
if (!language) return { shiki, language: 'text' as const }
let loading = languageLoads.get(language)
if (!loading) {
loading = shiki.loadLanguage(languageLoaders.get(language)!).catch(error => {
languageLoads.delete(language)
throw error
})
languageLoads.set(language, loading)
}
await loading
return { shiki, language }
}
export async function highlightCode(source: string, requestedLanguage = 'text'): Promise<string> {
const shiki = await highlighter
const language = languageAliases[requestedLanguage] ?? requestedLanguage
const loadedLanguage = shiki.getLoadedLanguages().includes(language as never) ? language : 'markdown'
const { shiki, language } = await loadCodeLanguage(requestedLanguage)
return shiki.codeToHtml(source, {
lang: loadedLanguage,
lang: language,
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false,
})
}
export async function renderMarkdown(source: string): Promise<string> {
/** Share the initialized grammar/theme registry with editable code blocks. */
export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', requestedLanguage = 'text') {
const { shiki } = await loadCodeLanguage(requestedLanguage)
return (source: string, requestedLanguage: string) => {
const language = languageAliases.get(requestedLanguage.toLowerCase()) ?? 'text'
return shiki.codeToTokens(source, {
lang: shiki.getLoadedLanguages().includes(language as never) ? language : 'text',
theme,
}).tokens
}
}
export async function renderMarkdown(source: string, options?: { theme?: 'light' | 'dark' }): Promise<string> {
const html = marked.parse(source, { async: false }) as string
const documentNode = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
const mermaidBlocks: { pre: Element; source: string }[] = []
for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
if (requestedLanguage === 'mermaid') {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '' })
continue
}
const highlighted = await highlightCode(code.textContent ?? '', requestedLanguage)
const fragment = document.createRange().createContextualFragment(highlighted)
code.parentElement?.replaceWith(fragment)
}
// Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
for (const { pre, source } of mermaidBlocks) {
try {
const result = await renderMermaid(source, { theme: options?.theme, mode: 'static' })
const container = document.createElement('div')
container.className = 'markdown-mermaid'
container.innerHTML = result.svg
pre.replaceWith(container)
} catch {
const fallback = document.createElement('pre')
fallback.className = 'mermaid-error'
fallback.textContent = source
pre.replaceWith(fallback)
}
}
return DOMPurify.sanitize(documentNode.body.innerHTML, {
USE_PROFILES: { html: true },
ADD_TAGS: ['svg', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
'text', 'tspan', 'textPath', 'g', 'defs', 'marker', 'style', 'clipPath', 'foreignObject',
'title', 'desc', 'use', 'image', 'linearGradient', 'stop', 'radialGradient'],
ADD_ATTR: ['viewBox', 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height',
'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin',
'transform', 'points', 'x1', 'y1', 'x2', 'y2', 'class', 'id', 'style', 'text-anchor',
'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'opacity', 'orient',
'marker-end', 'marker-start', 'marker-mid', 'refX', 'refY', 'viewBox', 'preserveAspectRatio',
'xlink:href', 'href', 'clip-path', 'gradientUnits', 'gradientTransform', 'stop-color',
'stop-opacity', 'offset', 'patternUnits', 'patternTransform', 'target'],
})
}
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。
+1
View File
@@ -9,6 +9,7 @@
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "ESNext.Disposable", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"baseUrl": ".",