From ab9ce58051e2475e40f005c2a25c50333e05df8f Mon Sep 17 00:00:00 2001 From: jhy <184825448@qq.com> Date: Fri, 4 Sep 2026 23:15:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20PR=20#18?= =?UTF-8?q?=20=E5=AE=A1=E9=98=85=E9=97=AE=E9=A2=98=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审阅意见逐项修复: 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(此前只拼成文本显示);补上必填校验与布尔字段初始值, 修正「显示否但不提交该键」的不一致。 补充回归测试 91 项(含对上述缺陷的变异验证): traceService / theme store / themePackageService / pluginCommandForm / useCitationNavigation / TraceTimeline。 vue-tsc -b、vitest(32 文件 182 项)、vite build 全部通过。 --- frontend/src/components/common/AppShell.vue | 17 +- .../composables/useCitationNavigation.spec.ts | 84 +++++++ .../src/composables/useCitationNavigation.ts | 64 +++++ frontend/src/contracts/index.ts | 4 + frontend/src/features/agent/AgentView.vue | 18 +- .../src/features/agent/TraceTimeline.spec.ts | 170 +++++++++++++ frontend/src/features/agent/TraceTimeline.vue | 96 +++++-- frontend/src/features/chat/ChatView.vue | 22 +- .../features/plugins/PluginCommandPanel.vue | 235 ++++++++++++++++++ .../src/features/plugins/PluginMcpPanel.vue | 89 +------ frontend/src/features/plugins/PluginsView.vue | 108 +------- frontend/src/features/themes/ThemesView.vue | 117 ++++----- frontend/src/main.ts | 3 +- .../src/services/pluginCommandForm.spec.ts | 227 +++++++++++++++++ frontend/src/services/pluginCommandForm.ts | 152 +++++++++++ .../src/services/themePackageService.spec.ts | Bin 4522 -> 8006 bytes frontend/src/services/themePackageService.ts | 83 +++++-- frontend/src/services/traceService.spec.ts | 113 +++++++-- frontend/src/services/traceService.ts | 160 ++++++++---- frontend/src/stores/theme.spec.ts | 135 +++++++++- frontend/src/stores/theme.ts | 59 ++++- frontend/src/stores/workspace.ts | 7 + 22 files changed, 1573 insertions(+), 390 deletions(-) create mode 100644 frontend/src/composables/useCitationNavigation.spec.ts create mode 100644 frontend/src/composables/useCitationNavigation.ts create mode 100644 frontend/src/features/agent/TraceTimeline.spec.ts create mode 100644 frontend/src/features/plugins/PluginCommandPanel.vue create mode 100644 frontend/src/services/pluginCommandForm.spec.ts create mode 100644 frontend/src/services/pluginCommandForm.ts diff --git a/frontend/src/components/common/AppShell.vue b/frontend/src/components/common/AppShell.vue index 2b1ac95..54373d5 100644 --- a/frontend/src/components/common/AppShell.vue +++ b/frontend/src/components/common/AppShell.vue @@ -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 }) diff --git a/frontend/src/composables/useCitationNavigation.spec.ts b/frontend/src/composables/useCitationNavigation.spec.ts new file mode 100644 index 0000000..4326c02 --- /dev/null +++ b/frontend/src/composables/useCitationNavigation.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import { navigateToCitation } from './useCitationNavigation' +import type { CitationNavigationDeps } from './useCitationNavigation' + +function deps(overrides: Partial = {}) { + 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() + }) +}) diff --git a/frontend/src/composables/useCitationNavigation.ts b/frontend/src/composables/useCitationNavigation.ts new file mode 100644 index 0000000..5a1ab19 --- /dev/null +++ b/frontend/src/composables/useCitationNavigation.ts @@ -0,0 +1,64 @@ +import { useRouter } from 'vue-router' +import { useEditorStore } from '@/stores/editor' +import { useWorkspaceStore } from '@/stores/workspace' + +/** + * 引用目标。字段用 unknown 是因为 Agent 事件流里拿到的是 + * Record(SSE 原始 data),不保证结构完整。 + */ +export interface CitationTarget { + file_path?: unknown + block_id?: unknown +} + +export interface CitationNavigationDeps { + loadFile: (filePath: string) => Promise + openFile: (filePath: string) => void + highlightBlock: (blockId: string) => void + navigate: (path: string) => Promise | 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 { + 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), + }), + } +} diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts index 86b6b46..cc51b6a 100644 --- a/frontend/src/contracts/index.ts +++ b/frontend/src/contracts/index.ts @@ -842,12 +842,16 @@ export interface ThemePackageInspection { 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' diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index 279a21a..2b468a6 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -8,12 +8,14 @@ import TraceTimeline from './TraceTimeline.vue' import type { AgentEvent } from '@/contracts' import { localizeDetails, permissionLabel, runStatusLabel, toolLabel } from './labels' import ToolOption from './ToolOption.vue' +import { useCitationNavigation } from '@/composables/useCitationNavigation' const route = useRoute() 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 +73,16 @@ function eventText(event: AgentEvent) { if (text) return String(text) return '' } + +/** Trace 里点引用 → 打开对应笔记块。失败原因要让用户看到,不能静默。 */ +async function handleOpenCitation(data: Record) { + pageError.value = '' + try { + await openCitation(data) + } catch (error) { + pageError.value = error instanceof Error ? error.message : '引用定位失败' + } +}