diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index eae3e0f..924c3b9 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -530,6 +530,89 @@ const api: DesktopApi = { retrySource: vi.fn(async () => {}), removeSource: vi.fn(async () => {}), search: vi.fn(async () => []), + retrieve: vi.fn(async (input) => ({ + query: input.query, + durationMs: 0, + settings: input.settings ?? { + version: 1, + topK: 6, + minimumVectorSimilarity: 0, + ftsWeight: 1, + vectorWeight: 1, + graphWeight: 0.8, + candidateMultiplier: 4, + contextMaxCharacters: 16_000, + adjacentChunkCount: 0, + localRerankEnabled: false, + rerankMode: 'none' + }, + diagnostics: { + requestedChannels: [], + usedChannels: [], + degradedChannels: [], + candidateCounts: {}, + channelDurationMs: {}, + vectorScannedCount: 0, + filteredByThresholdCount: 0, + filteredByBudgetCount: 0, + rerank: { + requested: 'none' as const, + used: 'none' as const, + status: 'skipped' as const, + candidateCount: 0, + durationMs: 0 + } + }, + results: [], + context: { + characterCount: 0, + truncated: false, + groups: [] + } + })), + updateSettings: vi.fn(async () => { + throw new Error('not used') + }), + listChunks: vi.fn(async () => ({ + items: [], + page: 1, + pageSize: 50, + totalItems: 0 + })), + updateChunk: vi.fn(async () => {}), + deleteChunk: vi.fn(async () => {}), + rebuildDocument: vi.fn(async () => ({ + libraries: [], + sources: [], + documents: [], + graphNodes: [], + graphRelations: [], + evidence: [] + })), + rebuildLibrary: vi.fn(async () => ({ + rebuilt: 0, + failed: 0 + })), + cancelRebuild: vi.fn(async () => true), + getEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({ + knowledgeBaseId, + enabled: false, + coverage: { total: 0, indexed: 0, missing: 0, error: 0 }, + indexStatus: { job: null } + })), + rebuildEmbeddingIndex: vi.fn(async (knowledgeBaseId: string) => ({ + knowledgeBaseId, + enabled: false, + coverage: { total: 0, indexed: 0, missing: 0, error: 0 }, + indexStatus: { job: null } + })), + cancelEmbeddingIndex: vi.fn(async () => false), + cancelTask: vi.fn(async () => false), + retryTask: vi.fn(async () => {}), + getReferenceContext: vi.fn(async () => { + throw new Error('not used') + }), + openReferenceSource: vi.fn(async () => {}), createEntity: vi.fn(async () => {}), updateEntity: vi.fn(async () => {}), moveEntity: vi.fn(async () => {}), @@ -1422,7 +1505,8 @@ describe('App', () => { const request = run.mock.calls[0]?.[0] expect(request).toMatchObject({ prompt: '发布流程是什么?', - knowledgeLibraryIds: [libraryId] + knowledgeLibraryIds: [libraryId], + knowledgeRetrievalMode: 'auto' }) expect(api.knowledge.search).not.toHaveBeenCalled() expect( @@ -1441,6 +1525,7 @@ describe('App', () => { libraryId, libraryName: '产品知识', documentId: crypto.randomUUID(), + chunkId: crypto.randomUUID(), documentName: `发布手册 ${batch}-${index}`, sourceName: `release-${batch}-${index}.md`, locator: `第 ${batch}-${index} 节`, @@ -1457,6 +1542,35 @@ describe('App', () => { expect( await screen.findByText('查看 20 条证据引用') ).toBeInTheDocument() + vi.mocked(api.knowledge.getReferenceContext).mockResolvedValueOnce({ + knowledgeBaseId: libraryId, + documentId: 'document-context', + chunkId: 'chunk-context', + documentTitle: '发布手册', + sourceDisplayName: 'release.md', + locator: '第 4 节', + matchedContent: '命中分块内容', + contextContent: '上文\n\n命中分块内容\n\n下文', + contextChunkIds: ['chunk-context'], + truncated: false + }) + fireEvent.click(screen.getByText('查看 20 条证据引用')) + fireEvent.click( + screen.getAllByRole('button', { name: '查看上下文' })[0]! + ) + expect( + await screen.findByRole('dialog', { name: '引用上下文' }) + ).toBeInTheDocument() + expect(api.knowledge.getReferenceContext).toHaveBeenCalledWith( + expect.objectContaining({ + knowledgeBaseId: libraryId + }) + ) + fireEvent.click( + screen.getAllByRole('button', { + name: '关闭引用上下文' + })[0]! + ) await waitFor( () => { const persistedMessages = vi @@ -1472,12 +1586,96 @@ describe('App', () => { (message) => message.sourceReferences?.length === 20 ) expect(persisted?.sourceReferences).toHaveLength(20) - expect(persisted?.sources).toHaveLength(100) + expect(persisted?.sources).toBeUndefined() }, { timeout: 2_000 } ) }) + it('persists and submits always-retrieve mode for the active conversation', async () => { + const libraryId = '11111111-1111-4111-8111-111111111111' + vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ + libraries: [ + { + id: libraryId, + name: '产品知识', + description: '', + storageMode: 'managed', + graphEnabled: false, + graphStrategy: 'rules', + sourceCount: 1, + documentCount: 1, + indexedDocumentCount: 1 + } + ], + sources: [], + documents: [], + graphNodes: [], + graphRelations: [], + evidence: [] + }) + render() + const knowledgeScope = await screen.findByRole('button', { + name: '选择知识库,本次已启用 1 个' + }) + fireEvent.click(knowledgeScope) + const retrievalMode = screen.getByRole('group', { + name: '知识检索方式' + }) + fireEvent.click( + within(retrievalMode).getByRole('button', { + name: '每次先检索' + }) + ) + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '必须查询发布流程' } + }) + fireEvent.click(screen.getByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + + expect(run.mock.calls[0]?.[0]).toMatchObject({ + prompt: '必须查询发布流程', + knowledgeLibraryIds: [libraryId], + knowledgeRetrievalMode: 'always' + }) + const request = run.mock.calls[0]?.[0] + if (!request) { + throw new Error('Missing request') + } + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'knowledge-retrieval', + mode: 'always', + state: 'degraded', + libraryCount: 1, + resultCount: 2, + durationMs: 18, + usedChannels: ['fts', 'cjk'], + warnings: ['向量模型未配置'] + }) + }) + expect( + await screen.findByText('知识检索已降级') + ).toBeInTheDocument() + expect( + screen.getByText(/已检索 1 个知识库,获得 2 条结果/u) + ).toBeInTheDocument() + expect(screen.getByText('向量模型未配置')).toBeInTheDocument() + await waitFor(() => { + const snapshots = vi + .mocked(api.conversations.replace) + .mock.calls.at(-1)?.[0] + expect( + snapshots?.some( + (conversation) => + conversation.knowledgeRetrievalMode === 'always' + ) + ).toBe(true) + }) + }) + it('keeps a running response visible when cancellation fails', async () => { vi.mocked(api.agent.cancel).mockRejectedValueOnce( new Error('cancel failed') @@ -2358,34 +2556,6 @@ describe('App', () => { ) }) - it('normalizes a legacy Plan project default to Ask', async () => { - vi.mocked(api.projects.list).mockResolvedValueOnce([ - { - ...project, - defaultWorkMode: 'plan' - } - ]) - render() - - const mode = await screen.findByRole('button', { - name: '工作模式:Ask · 只读问答' - }) - expect(mode).toBeEnabled() - fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { - target: { value: '制定发布方案' } - }) - fireEvent.click(screen.getByLabelText('发送')) - - await waitFor(() => - expect(run).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '制定发布方案', - workMode: 'ask' - }) - ) - ) - }) - it('restores and persists the last active project', async () => { const secondProject = { ...project, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 39c5c52..58dfa96 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -56,6 +56,7 @@ import type { BrowserLiveState, ContextAttachment, ContextFileSelectionProgress, + KnowledgeRetrievalMode, KnowledgeSearchReference, KnowledgeSnapshot, RuntimeSettings @@ -104,6 +105,10 @@ import { type ActivityRecord } from './activity-store' import { KnowledgeWorkspace } from './KnowledgeWorkspace' +import { + KnowledgeCitationDialog, + type KnowledgeCitationContextView +} from './KnowledgeCitationDialog' import { HeartbeatCenter } from './HeartbeatCenter' import { MagicNotesWorkspace } from './MagicNotesWorkspace' import { MarkdownRenderer } from './MarkdownRenderer' @@ -111,6 +116,7 @@ import { DestructiveConfirmActions, EmptyState, PageShell, + SegmentedControl, ScopeBadge } from './WorkspacePrimitives' import { @@ -289,7 +295,7 @@ function isAgentRuntime( function supportsSubagentSmartRouting( workMode: string ): boolean { - return workMode === 'ask' || ['plan'].includes(workMode) + return workMode === 'ask' } type ToolActivity = ConversationToolActivity @@ -304,6 +310,11 @@ type SubagentActivity = { error?: string } +type KnowledgeRetrievalStatus = Omit< + Extract, + 'requestId' | 'type' +> + type Message = { id: string role: 'user' | 'assistant' @@ -326,6 +337,7 @@ type Message = { question?: Extract sources?: string[] sourceReferences?: KnowledgeSearchReference[] + knowledgeRetrieval?: KnowledgeRetrievalStatus artifactIds?: string[] attachments?: ConversationAttachment[] } @@ -334,6 +346,7 @@ type Conversation = { id: string projectId?: string runtimeSelection?: AgentRuntimeSelection + knowledgeRetrievalMode?: KnowledgeRetrievalMode remote?: ConversationSnapshot['remote'] title: string updatedAt: number @@ -586,6 +599,7 @@ function createConversation( id: crypto.randomUUID(), projectId, runtimeSelection, + knowledgeRetrievalMode: 'auto', title: '新对话', updatedAt: now, messages: [ @@ -693,6 +707,9 @@ function isConversation(value: unknown): value is Conversation { (item.runtimeSelection === undefined || agentRuntimeSelectionSchema.safeParse(item.runtimeSelection) .success) && + (item.knowledgeRetrievalMode === undefined || + item.knowledgeRetrievalMode === 'auto' || + item.knowledgeRetrievalMode === 'always') && (item.remote === undefined || (typeof item.remote === 'object' && item.remote !== null && @@ -744,6 +761,7 @@ function toConversationSnapshots( id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, + knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, remote: conversation.remote, title: conversation.title, updatedAt: conversation.updatedAt, @@ -759,6 +777,7 @@ function toConversationSnapshots( tools: message.tools, sources: message.sources, sourceReferences: message.sourceReferences, + knowledgeRetrieval: message.knowledgeRetrieval, artifactIds: message.artifactIds, attachments: message.attachments })) @@ -1507,6 +1526,12 @@ function App(): React.JSX.Element { const selectingContextFilesRef = useRef(false) const [imageViewerItem, setImageViewerItem] = useState() + const [citationDialog, setCitationDialog] = useState<{ + reference: KnowledgeSearchReference + context?: KnowledgeCitationContextView + loading: boolean + error?: string + }>() const imageViewerTriggerRef = useRef( undefined ) @@ -2663,6 +2688,23 @@ function App(): React.JSX.Element { ].slice(-8), status: tRef.current('chat.status.savingImage') })) + } else if (event.type === 'knowledge-retrieval') { + updateMessage(run.conversationId, run.messageId, (message) => ({ + ...message, + knowledgeRetrieval: { + mode: event.mode, + state: event.state, + libraryCount: event.libraryCount, + resultCount: event.resultCount, + durationMs: event.durationMs, + usedChannels: event.usedChannels, + warnings: event.warnings + }, + status: + event.state === 'searching' + ? tRef.current('chat.knowledgeRetrieval.searching') + : undefined + })) } else if (event.type === 'source-references') { updateMessage(run.conversationId, run.messageId, (message) => { const referenceKey = ( @@ -2671,6 +2713,7 @@ function App(): React.JSX.Element { [ reference.libraryId, reference.documentId, + reference.chunkId ?? '', reference.locator ?? '', reference.snippet ].join('\0') @@ -2689,21 +2732,9 @@ function App(): React.JSX.Element { (reference) => !incomingKeys.has(referenceKey(reference)) ) ].slice(0, 20) - const referenceSources = references.map( - (reference) => - `${reference.libraryName} / ${reference.documentName}${ - reference.locator ? ` (${reference.locator})` : '' - }` - ) return { ...message, - sourceReferences: references, - sources: [ - ...new Set([ - ...referenceSources, - ...(message.sources ?? []) - ]) - ].slice(0, 100) + sourceReferences: references } }) } else { @@ -3937,6 +3968,53 @@ function App(): React.JSX.Element { }) } + const openCitationContext = async ( + reference: KnowledgeSearchReference + ): Promise => { + setCitationDialog({ + reference, + loading: true + }) + if (!reference.chunkId) { + setCitationDialog({ + reference, + loading: false, + error: t('chat.citations.contextUnavailable') + }) + return + } + try { + const context = + await window.goodbuddy.knowledge.getReferenceContext({ + knowledgeBaseId: reference.libraryId, + documentId: reference.documentId, + chunkId: reference.chunkId + }) + setCitationDialog({ + reference, + loading: false, + context: { + libraryName: reference.libraryName, + documentName: context.documentTitle, + sourceName: context.sourceDisplayName, + locator: context.locator, + matchedContent: context.matchedContent, + contextContent: context.contextContent, + truncated: context.truncated + } + }) + } catch (reason) { + setCitationDialog({ + reference, + loading: false, + error: + reason instanceof Error + ? reason.message + : t('chat.citations.contextUnavailable') + }) + } + } + const downloadImage = (item: ImageViewerItem): void => { if (!imageDataUrlPattern.test(item.src)) { notify({ tone: 'error', message: t('notices.imageUnavailable') }) @@ -4011,6 +4089,8 @@ function App(): React.JSX.Element { const attachmentSnapshot = attachments.slice(0, 8) const historySnapshot = activeConversation.messages const projectIdSnapshot = activeProjectId || undefined + const knowledgeRetrievalModeSnapshot = + activeConversation.knowledgeRetrievalMode ?? 'auto' const runtimeSelectionSnapshot = activeRuntimeSelection if (!runtimeSelectionSnapshot) { notify({ tone: 'info', message: t('runtime.notSelected') }) @@ -4132,6 +4212,7 @@ function App(): React.JSX.Element { workMode: workModeSnapshot, prompt: executionPrompt, knowledgeLibraryIds: enabledKnowledgeLibraryIds, + knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot, contextIds: attachmentSnapshot.map( (attachment) => attachment.id ), @@ -4678,7 +4759,7 @@ function App(): React.JSX.Element {
GoodBuddy - AI desktop companion + Desktop workspace
@@ -5500,6 +5581,51 @@ function App(): React.JSX.Element { ) : null })} + {message.knowledgeRetrieval && ( +
+
+ )} {message.sources && message.sources.length > 0 && (
@@ -5524,7 +5650,7 @@ function App(): React.JSX.Element { {message.sourceReferences.map( (reference, referenceIndex) => (
  • [{referenceIndex + 1}]{' '} @@ -5541,6 +5667,8 @@ function App(): React.JSX.Element { .map((channel) => channel === 'fts' ? t('chat.citations.fullText') + : channel === 'cjk' + ? t('chat.citations.cjk') : channel === 'vector' ? t('chat.citations.vector') : t('chat.citations.graph') @@ -5548,6 +5676,55 @@ function App(): React.JSX.Element { .join(' + ')} )} + {reference.score !== undefined && ( + + {t('chat.citations.score', { + score: reference.score.toFixed(4) + })} + + )} +
    + + +
  • ) )} @@ -5998,6 +6175,47 @@ function App(): React.JSX.Element { ))} +
    + + {t('composer.knowledge.modeLabel')} + + + setConversations((current) => + current.map((conversation) => + conversation.id === activeId + ? { + ...conversation, + knowledgeRetrievalMode: mode, + updatedAt: Date.now() + } + : conversation + ) + ) + } + options={[ + { + value: 'auto', + label: t('composer.knowledge.auto') + }, + { + value: 'always', + label: t('composer.knowledge.always') + } + ]} + value={ + activeConversation?.knowledgeRetrievalMode ?? + 'auto' + } + /> + + {activeConversation?.knowledgeRetrievalMode === + 'always' + ? t('composer.knowledge.alwaysDescription') + : t('composer.knowledge.autoDescription')} + +
    )} @@ -6460,11 +6678,123 @@ function App(): React.JSX.Element { window.goodbuddy.knowledge.removeSource(sourceId) ) } + onRetrieve={(libraryId, query, settings) => + window.goodbuddy.knowledge.retrieve({ + knowledgeBaseId: libraryId, + query, + settings + }) + } + onUpdateKnowledgeSettings={async ( + libraryId, + settings + ) => { + await runKnowledgeSourceAction(() => + window.goodbuddy.knowledge.updateSettings({ + knowledgeBaseId: libraryId, + ...settings + }) + ) + notify({ + tone: 'success', + message: t('notices.knowledgeSettingsUpdated'), + dedupeKey: `knowledge-retrieval-settings:${libraryId}` + }) + }} + onListChunks={({ + libraryId, + documentId, + page, + pageSize, + search + }) => + window.goodbuddy.knowledge.listChunks({ + knowledgeBaseId: libraryId, + documentId, + page, + pageSize, + search + }) + } + onUpdateChunk={(input) => + window.goodbuddy.knowledge.updateChunk(input) + } + onDeleteChunk={(input) => + window.goodbuddy.knowledge.deleteChunk(input) + } + onRebuildDocument={(libraryId, documentId) => + runKnowledgeSourceAction(async () => { + await window.goodbuddy.knowledge.rebuildDocument({ + knowledgeBaseId: libraryId, + documentId + }) + }) + } + onRebuildLibrary={(libraryId) => + runKnowledgeSourceAction(async () => { + const result = + await window.goodbuddy.knowledge.rebuildLibrary({ + knowledgeBaseId: libraryId + }) + if (result.failed > 0) { + throw new Error( + t('notices.knowledgeRebuildPartial', { + rebuilt: result.rebuilt, + failed: result.failed + }) + ) + } + notify({ + tone: 'success', + message: t('notices.knowledgeRebuildCompleted', { + count: result.rebuilt + }), + dedupeKey: `knowledge-rebuild:${libraryId}` + }) + }) + } + onCancelRebuild={async (libraryId) => { + const cancelled = + await window.goodbuddy.knowledge.cancelRebuild( + libraryId + ) + if (!cancelled) { + throw new Error( + t('notices.knowledgeRebuildNotRunning') + ) + } + }} + onGetEmbeddingIndex={(libraryId) => + window.goodbuddy.knowledge.getEmbeddingIndex(libraryId) + } + onRebuildEmbeddingIndex={(libraryId) => + window.goodbuddy.knowledge.rebuildEmbeddingIndex( + libraryId + ) + } + onCancelTask={async (taskId) => { + const cancelled = + await window.goodbuddy.knowledge.cancelTask(taskId) + if (!cancelled) { + throw new Error( + t('notices.knowledgeTaskNotRunning') + ) + } + await refreshSelectedKnowledge() + }} + onOpenReferenceSource={(input) => + window.goodbuddy.knowledge.openReferenceSource(input) + } onRetrySource={(sourceId) => runKnowledgeSourceAction(() => window.goodbuddy.knowledge.retrySource(sourceId) ) } + onRetryTask={(taskId) => + runKnowledgeSourceAction(() => + window.goodbuddy.knowledge.retryTask(taskId) + ) + } onRetryLoad={retryKnowledgeLoad} onSelectLibrary={(libraryId) => { void refreshKnowledge(libraryId).catch(() => { @@ -6579,6 +6909,26 @@ function App(): React.JSX.Element { snapshot={releaseNotes} /> )} + {citationDialog && ( + setCitationDialog(undefined)} + onOpenSource={async () => { + const { reference } = citationDialog + if (!reference.chunkId) { + throw new Error(t('chat.citations.contextUnavailable')) + } + await window.goodbuddy.knowledge.openReferenceSource({ + knowledgeBaseId: reference.libraryId, + documentId: reference.documentId, + chunkId: reference.chunkId + }) + }} + reference={citationDialog.reference} + /> + )} {imageViewerItem && (
    { cleanup() }) describe('EmbeddingSettingsSection', () => { - it('renders embedding settings in English without translating model data', async () => { + it('keeps model settings limited to connection details and diagnostics', () => { + const onTest = vi.fn() + render( + + ) + + expect( + screen.getByRole('heading', { name: '向量模型连接' }) + ).toBeInTheDocument() + expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument() + expect(screen.getByText('已配置凭据')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /重建向量索引/u }) + ).not.toBeInTheDocument() + expect(screen.queryByText(/知识向量索引/u)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '测试向量模型' })) + expect(onTest).toHaveBeenCalledOnce() + }) + + it('renders connection copy in English without translating model data', async () => { await changeUiLocale('en-US') render( ) expect( screen.getByRole('heading', { - name: 'Embeddings and knowledge retrieval' + name: 'Embedding model connection' }) ).toBeInTheDocument() - expect( - screen.getByRole('heading', { name: 'Current embedding model' }) - ).toBeInTheDocument() expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument() - expect(screen.getByText('Provider: openai-compatible')) - .toBeInTheDocument() expect( screen.getByRole('button', { name: 'Test embedding model' }) ).toBeInTheDocument() - expect(screen.getByText('No rebuild history yet')).toBeInTheDocument() - }) - - it('uses supplied callbacks without depending on a preload API', () => { - const onTest = vi.fn() - const onRebuild = vi.fn() - render( - - ) - - expect( - screen.getByRole('heading', { name: '向量与知识检索' }) - ).toBeInTheDocument() - expect( - screen.getByRole('heading', { name: '当前向量模型' }) - ).toBeInTheDocument() - expect(screen.getByText('text-embedding-3-small')).toBeInTheDocument() - expect(screen.getByText('已配置凭据')).toBeInTheDocument() - expect(screen.getByText('还没有重建记录')).toBeInTheDocument() - expect( - screen.getByText( - '点击“重建向量索引”,为知识文档生成可用于检索的向量。' - ) - ).toBeInTheDocument() - expect(screen.queryByText(/快照/)).not.toBeInTheDocument() - expect(screen.queryByText(/当前检索索引/)).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: '测试向量模型' })) - fireEvent.click(screen.getByRole('button', { name: '重建向量索引' })) - expect(onTest).toHaveBeenCalledOnce() - expect(onRebuild).toHaveBeenCalledOnce() }) it('shows dimensions and latency from a real diagnostic result', () => { @@ -103,8 +78,6 @@ describe('EmbeddingSettingsSection', () => { latencyMs: 126, dimensions: 1_536 }} - indexStatus={idleIndex} - onRebuild={vi.fn()} onTest={vi.fn()} /> ) @@ -132,8 +105,6 @@ describe('EmbeddingSettingsSection', () => { remedy: '请确认模型名称正确。' } }} - indexStatus={idleIndex} - onRebuild={vi.fn()} onTest={vi.fn()} /> ) @@ -145,142 +116,4 @@ describe('EmbeddingSettingsSection', () => { '处理建议:请确认模型名称正确。' ) }) - - it('shows document progress and atomic availability while rebuilding', () => { - const onCancel = vi.fn() - render( - - ) - - expect(screen.getByRole('progressbar')).toHaveAttribute('value', '25') - expect(screen.getByText('已完成 10 / 40 篇文档')).toBeInTheDocument() - expect( - screen.getByText(/每篇文档会一次性更新,处理完成后立即可用于检索。/) - ).toBeInTheDocument() - expect( - screen.getByText(/其余文档的原有或缺失状态不变。/) - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: '重建进行中…' }) - ).toBeDisabled() - - fireEvent.click( - screen.getByRole('button', { name: '取消向量索引重建' }) - ) - expect(onCancel).toHaveBeenCalledWith('job-new') - }) - - it('shows a failed rebuild remedy and retries from the rebuild button', () => { - const onRebuild = vi.fn() - render( - - ) - - expect(screen.getByText('最近一次重建失败')).toBeInTheDocument() - expect(screen.getByRole('alert')).toHaveTextContent( - '向量服务当前请求过多。' - ) - expect(screen.getByRole('alert')).toHaveTextContent( - '已完成 2 / 4 篇文档。发生错误的文档已标记为错误,已完成文档仍可用于检索。' - ) - expect(screen.getByRole('alert')).toHaveTextContent( - '请检查向量模型配置和网络连接。修复后点击“重建向量索引”重试。' - ) - - fireEvent.click(screen.getByRole('button', { name: '重建向量索引' })) - expect(onRebuild).toHaveBeenCalledOnce() - }) - - it('reports successful and cancelled rebuilds distinctly', () => { - const { rerender } = render( - - ) - expect(screen.getByText('最近一次重建成功')).toBeInTheDocument() - expect(screen.getByText('已完成 4 / 4 篇文档', { exact: false })) - .toBeInTheDocument() - - rerender( - - ) - expect(screen.getByText('最近一次重建已取消')).toBeInTheDocument() - expect( - screen.getByText('已完成 2 / 4 篇文档。') - ).toBeInTheDocument() - expect( - screen.getByText(/已完成文档保留新向量;其余文档保留原有向量/) - ).toBeInTheDocument() - expect(screen.getByText(/原本没有向量的仍保持缺失。/)) - .toBeInTheDocument() - expect(screen.queryByText(/索引未更改/)).not.toBeInTheDocument() - }) }) diff --git a/src/renderer/src/EmbeddingSettingsSection.tsx b/src/renderer/src/EmbeddingSettingsSection.tsx index 4ff146a..12d66ee 100644 --- a/src/renderer/src/EmbeddingSettingsSection.tsx +++ b/src/renderer/src/EmbeddingSettingsSection.tsx @@ -1,35 +1,20 @@ import { Activity, - Database, - FlaskConical, - RefreshCw, - XCircle + FlaskConical } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { EmbeddingConfigurationSummary, - EmbeddingDiagnosticResult, - EmbeddingIndexJob, - EmbeddingIndexStatus + EmbeddingDiagnosticResult } from '../../shared/embedding-contracts' -import { isEmbeddingIndexJobActive } from '../../shared/embedding-contracts' +import { formatMediumDateTime } from './locale-formatters' export interface EmbeddingSettingsSectionProps { configuration: EmbeddingConfigurationSummary diagnostic?: EmbeddingDiagnosticResult | null diagnosticRunning?: boolean - indexStatus: EmbeddingIndexStatus disabled?: boolean onTest: () => void - onRebuild: () => void - onCancel?: (jobId: string) => void -} - -function formatCheckedAt(timestamp: number, locale: string): string { - return new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short' - }).format(timestamp) } function DiagnosticResult({ @@ -51,7 +36,7 @@ function DiagnosticResult({

    {t('embedding.diagnostic.checkedAt', { - date: formatCheckedAt(result.checkedAt, locale) + date: formatMediumDateTime(result.checkedAt, locale) })}
    @@ -76,123 +61,14 @@ function DiagnosticResult({ ) } -function IndexJobStatus({ - job, - disabled, - onCancel -}: { - job: EmbeddingIndexJob - disabled: boolean - onCancel?: (jobId: string) => void -}): React.JSX.Element { - const { i18n, t } = useTranslation('settingsSections') - const locale = i18n.resolvedLanguage ?? i18n.language - const active = isEmbeddingIndexJobActive(job) - return ( -
    -
    -
    - {t(`embedding.index.statuses.${job.status}`)} - - {job.provider} · {job.model} - -
    - {active && onCancel && ( - - )} -
    - {active && ( - <> - 0 - ? { value: job.progress.percent } - : {})} - /> -

    - {job.progress.total > 0 - ? t('embedding.index.completed', { - completed: job.progress.completed, - total: job.progress.total - }) - : t('embedding.index.preparing')} -

    -

    - {t('embedding.index.atomicNotice')} -

    - - )} - {job.status === 'completed' && ( -

    - {job.completedAt - ? t('embedding.index.completedAt', { - completed: job.progress.completed, - total: job.progress.total, - date: formatCheckedAt(job.completedAt, locale) - }) - : t('embedding.index.completedWithPeriod', { - completed: job.progress.completed, - total: job.progress.total - })} -

    - )} - {job.status === 'cancelled' && ( - <> -

    - {t('embedding.index.completedWithPeriod', { - completed: job.progress.completed, - total: job.progress.total - })} -

    -

    {t('embedding.index.cancelledNotice')}

    - - )} - {job.status === 'failed' && job.error && ( -
    -

    {job.error.message}

    -

    - {t('embedding.index.failedNotice', { - completed: job.progress.completed, - total: job.progress.total - })} -

    -

    - {t('embedding.index.remedyPrefix')} - {job.error.remedy ?? t('embedding.index.defaultRemedy')} - {t('embedding.index.retrySuffix')} -

    -
    - )} -
    - ) -} - export function EmbeddingSettingsSection({ configuration, diagnostic, diagnosticRunning = false, - indexStatus, disabled = false, - onTest, - onRebuild, - onCancel + onTest }: EmbeddingSettingsSectionProps): React.JSX.Element { const { t } = useTranslation('settingsSections') - const active = isEmbeddingIndexJobActive(indexStatus.job) return (
    -
    -
    -
    -
    - -
    - - {indexStatus.job ? ( - - ) : ( -
    - {t('embedding.index.emptyTitle')} -

    {t('embedding.index.emptyDescription')}

    -
    - )} -
    ) } diff --git a/src/renderer/src/KnowledgeChunkManager.test.tsx b/src/renderer/src/KnowledgeChunkManager.test.tsx new file mode 100644 index 0000000..0aba788 --- /dev/null +++ b/src/renderer/src/KnowledgeChunkManager.test.tsx @@ -0,0 +1,241 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useState } from 'react' +import { + KnowledgeChunkManager, + type KnowledgeChunkManagerProps, + type KnowledgeChunkPage +} from './KnowledgeChunkManager' +import { changeUiLocale } from './i18n' + +const chunkPage: KnowledgeChunkPage = { + page: 1, + pageSize: 20, + totalItems: 21, + items: [ + { + id: 'chunk-1', + ordinal: 1, + role: 'child', + parentChunkId: 'parent-1', + heading: '安装模型', + locator: '第 3 节', + characterCount: 18, + enabled: true, + content: '可以导入已经校验的模型 ZIP。', + manuallyEdited: true + }, + { + id: 'chunk-2', + ordinal: 2, + role: 'standalone', + locator: '第 4 节', + characterCount: 12, + enabled: false, + content: '服务不可用时使用本地回退。', + manuallyEdited: false + } + ] +} + +function createProps( + overrides: Partial = {} +): KnowledgeChunkManagerProps { + return { + documentId: 'document-1', + documentName: '离线部署.md', + page: chunkPage, + onList: vi.fn(), + onUpdateChunk: vi.fn(), + onDeleteChunk: vi.fn(), + onRebuildDocument: vi.fn(), + onClose: vi.fn(), + ...overrides + } +} + +afterEach(async () => { + cleanup() + await changeUiLocale('zh-CN') +}) + +describe('KnowledgeChunkManager', () => { + it('provides dialog semantics, focuses search, traps focus, and returns focus on Escape', async () => { + function Harness(): React.JSX.Element { + const [open, setOpen] = useState(false) + return ( + <> +
    + +
    + {open && ( + setOpen(false) })} + /> + )} + + ) + } + + render() + const trigger = screen.getByRole('button', { name: '查看分块' }) + trigger.focus() + fireEvent.click(trigger) + + const dialog = screen.getByRole('dialog', { name: '文档分块' }) + expect(dialog).toHaveAttribute('aria-modal', 'true') + expect(screen.getByLabelText('搜索文档内分块')).toHaveFocus() + expect(screen.getAllByRole('switch')).toHaveLength(2) + expect( + screen.getByText('人工修改可能被替换。') + ).toBeInTheDocument() + + const rebuild = screen.getByRole('button', { name: '重建文档' }) + rebuild.focus() + fireEvent.keyDown(rebuild, { key: 'Tab' }) + expect( + screen.getByRole('button', { name: '关闭文档分块' }) + ).toHaveFocus() + + fireEvent.keyDown(dialog, { key: 'Escape' }) + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + ) + expect(trigger).toHaveFocus() + }) + + it('searches and paginates with a bounded list request', () => { + const onList = vi.fn() + render() + + fireEvent.change(screen.getByLabelText('搜索文档内分块'), { + target: { value: '模型' } + }) + fireEvent.click(screen.getByRole('button', { name: '搜索' })) + expect(onList).toHaveBeenCalledWith({ + documentId: 'document-1', + page: 1, + pageSize: 20, + query: '模型' + }) + + fireEvent.click(screen.getByRole('button', { name: '下一页分块' })) + expect(onList).toHaveBeenLastCalledWith({ + documentId: 'document-1', + page: 2, + pageSize: 20, + query: '模型' + }) + }) + + it('switches enablement, edits content, validates, and preserves a failed draft', () => { + const onUpdateChunk = vi.fn() + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: '启用分块 1' })) + expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', { + enabled: false + }) + + const editor = screen.getByLabelText(/^分块内容/) + fireEvent.change(editor, { target: { value: '保留的人工修正文稿' } }) + fireEvent.click(screen.getByRole('button', { name: '保存分块' })) + expect(onUpdateChunk).toHaveBeenCalledWith('chunk-1', { + content: '保留的人工修正文稿' + }) + + rerender( + + ) + expect(screen.getByRole('alert')).toHaveTextContent( + '向量重建失败,请重试。' + ) + expect( + screen.getByRole('switch', { name: '启用分块 1' }) + ).toBeChecked() + expect(screen.getByLabelText(/^分块内容/)).toHaveValue( + '保留的人工修正文稿' + ) + + fireEvent.change(screen.getByLabelText(/^分块内容/), { + target: { value: '' } + }) + expect(screen.getByText('分块内容不能为空。')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '保存分块' })).toBeDisabled() + }) + + it('confirms a specific deletion, restores trigger focus on cancel, and invokes rebuild', () => { + const onDeleteChunk = vi.fn() + const onRebuildDocument = vi.fn() + render( + + ) + + const deleteTrigger = screen.getByRole('button', { + name: '删除分块 1' + }) + fireEvent.click(deleteTrigger) + const confirmation = screen.getByRole('alertdialog', { + name: '确认删除分块 1' + }) + expect(confirmation).toHaveAccessibleDescription( + '删除分块 1 会移除其全文、中文、向量和图谱证据。来源同步或重建可能重新创建此分块;原始文件不会被删除。' + ) + expect(screen.getByRole('button', { name: '取消' })).toHaveFocus() + + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect( + screen.getByRole('button', { name: '删除分块 1' }) + ).toHaveFocus() + + fireEvent.click(screen.getByRole('button', { name: '删除分块 1' })) + fireEvent.click( + screen.getByRole('button', { name: '确认删除分块 1' }) + ) + expect(onDeleteChunk).toHaveBeenCalledWith('chunk-1') + + fireEvent.click(screen.getByRole('button', { name: '重建文档' })) + expect(onRebuildDocument).toHaveBeenCalledWith('document-1') + }) + + it('renders loading and zero states without a selected editor', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('status')).toHaveTextContent('正在加载分块') + + rerender( + + ) + expect(screen.getByRole('status')).toHaveTextContent( + '没有符合条件的分块' + ) + expect(screen.getByText('选择一个分块')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/KnowledgeChunkManager.tsx b/src/renderer/src/KnowledgeChunkManager.tsx new file mode 100644 index 0000000..6a03888 --- /dev/null +++ b/src/renderer/src/KnowledgeChunkManager.tsx @@ -0,0 +1,498 @@ +import { + ChevronLeft, + ChevronRight, + FilePenLine, + RefreshCw, + Search, + Trash2, + X +} from 'lucide-react' +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type FormEvent +} from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import type { + KnowledgeChunkPage as SharedKnowledgeChunkPage, + KnowledgeChunkRole as SharedKnowledgeChunkRole, + KnowledgeChunkUpdateInput, + KnowledgeManagedChunk as SharedKnowledgeManagedChunk +} from '../../shared/knowledge-contracts' +import { activateModalFocus, trapTabFocus } from './dialog-focus' +import { DestructiveConfirmActions } from './WorkspacePrimitives' + +export type KnowledgeChunkRole = SharedKnowledgeChunkRole +export type KnowledgeManagedChunk = SharedKnowledgeManagedChunk +export type KnowledgeChunkPage = SharedKnowledgeChunkPage +export type KnowledgeChunkUpdate = Pick< + KnowledgeChunkUpdateInput, + 'content' | 'enabled' +> + +export type KnowledgeChunkManagerProps = { + documentId: string + documentName: string + page: KnowledgeChunkPage + query?: string + selectedChunkId?: string + loading?: boolean + error?: string + savingChunkId?: string + deletingChunkId?: string + rebuilding?: boolean + maxChunkCharacters?: number + onList: (request: { + documentId: string + page: number + pageSize: number + query: string + }) => void | Promise + onSelectChunk?: (chunkId: string) => void + onUpdateChunk: ( + chunkId: string, + update: KnowledgeChunkUpdate + ) => void | Promise + onDeleteChunk: (chunkId: string) => void | Promise + onRebuildDocument: (documentId: string) => void | Promise + onClose: () => void +} + +export function KnowledgeChunkManager({ + deletingChunkId, + documentId, + documentName, + error, + loading = false, + maxChunkCharacters = 48_000, + onClose, + onDeleteChunk, + onList, + onRebuildDocument, + onSelectChunk, + onUpdateChunk, + page, + query = '', + rebuilding = false, + savingChunkId, + selectedChunkId +}: KnowledgeChunkManagerProps): React.JSX.Element { + const { t } = useTranslation('knowledge') + const [searchDraft, setSearchDraft] = useState(query) + const [internalSelectedId, setInternalSelectedId] = useState( + selectedChunkId ?? page.items[0]?.id + ) + const [contentDrafts, setContentDrafts] = useState>({}) + const [confirmingDeleteId, setConfirmingDeleteId] = useState() + const dialogRef = useRef(null) + const searchRef = useRef(null) + const titleId = useId() + const descriptionId = useId() + const contentErrorId = useId() + + const requestedSelectedId = selectedChunkId ?? internalSelectedId + const selectedChunk = + page.items.find((chunk) => chunk.id === requestedSelectedId) ?? + page.items[0] + const effectiveSelectedId = selectedChunk?.id + const draftContent = selectedChunk + ? (contentDrafts[selectedChunk.id] ?? selectedChunk.content) + : '' + const isSavingSelected = savingChunkId === selectedChunk?.id + const totalPages = Math.max(1, Math.ceil(page.totalItems / page.pageSize)) + + useEffect(() => { + return activateModalFocus(() => searchRef.current) + }, []) + + const contentError = useMemo(() => { + if (!selectedChunk) { + return undefined + } + if (draftContent.trim().length === 0) { + return t('chunks.validation.contentRequired') + } + if (draftContent.length > maxChunkCharacters) { + return t('chunks.validation.contentTooLong', { + count: maxChunkCharacters + }) + } + return undefined + }, [draftContent, maxChunkCharacters, selectedChunk, t]) + + const selectChunk = (chunkId: string): void => { + setInternalSelectedId(chunkId) + onSelectChunk?.(chunkId) + } + + const list = (nextPage: number, nextQuery = searchDraft.trim()): void => { + void onList({ + documentId, + page: nextPage, + pageSize: page.pageSize, + query: nextQuery + }) + } + + const submitSearch = (event: FormEvent): void => { + event.preventDefault() + list(1) + } + + const updateEnabled = (chunk: KnowledgeManagedChunk, enabled: boolean): void => { + void onUpdateChunk(chunk.id, { enabled }) + } + + const saveContent = (): void => { + if (!selectedChunk || contentError || isSavingSelected) { + return + } + void onUpdateChunk(selectedChunk.id, { content: draftContent }) + } + + const deleteChunk = (chunkId: string): void => { + void Promise.resolve(onDeleteChunk(chunkId)).then( + () => setConfirmingDeleteId(undefined), + () => undefined + ) + } + + return createPortal( +
    +
    { + if (event.defaultPrevented) { + return + } + if ( + event.key === 'Escape' && + !savingChunkId && + !deletingChunkId && + !rebuilding + ) { + event.preventDefault() + onClose() + return + } + trapTabFocus(event, dialogRef.current) + }} + ref={dialogRef} + role="dialog" + > +
    +
    + {documentName} +

    {t('chunks.title')}

    +

    {t('chunks.description')}

    +
    + +
    + +
    +
    + +
    + + +
    + {selectedChunk ? ( + <> +
    +
    +

    + {t('chunks.editor.title', { + count: selectedChunk.ordinal + })} +

    +

    + {t('chunks.editor.metadata', { + role: t(`chunks.roles.${selectedChunk.role}`), + locator: + selectedChunk.locator ?? t('chunks.unknownLocator') + })} +

    +
    + {selectedChunk.manuallyEdited && ( + + {t('chunks.editor.manuallyEdited')} + + )} +
    + + {selectedChunk.parentChunkId && ( +
    +
    +
    {t('chunks.editor.role')}
    +
    {t(`chunks.roles.${selectedChunk.role}`)}
    +
    +
    +
    {t('chunks.editor.parent')}
    +
    {selectedChunk.parentChunkId}
    +
    +
    + )} + +