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 : '引用定位失败' + } +}