diff --git a/docs/features/knowledge-base/README.md b/docs/features/knowledge-base/README.md index c712cf7b..b80d9725 100644 --- a/docs/features/knowledge-base/README.md +++ b/docs/features/knowledge-base/README.md @@ -1,7 +1,8 @@ # 知识库 -知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答,也负责将 -Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。 +知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。 +将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围仍处于规划阶段,不属于 +当前已提供能力。 ## 文档导航 @@ -9,6 +10,8 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。 | --- | --- | | [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 | | [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 | +| [UI 设计](./ui-design.md) | 当前本地知识库的信息架构、首次使用、状态与高级功能分层 | +| [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 | | [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 | | [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 | | [外部知识库接入 PRD](./external-knowledge-prd.md) | 外部实例、知识库绑定、检索范围和产品验收 | @@ -27,4 +30,5 @@ Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。 或 Agent,也不在外部实例中创建、修改、上传或删除知识库内容。远端资料不会批量同步 或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。 -当前尚未拆出覆盖整个知识库功能家族的统一逻辑、UI 设计和功能进度文档。 +本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约 +共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。 diff --git a/docs/features/knowledge-base/prd.md b/docs/features/knowledge-base/prd.md index a989bcb3..fe39b793 100644 --- a/docs/features/knowledge-base/prd.md +++ b/docs/features/knowledge-base/prd.md @@ -5,8 +5,8 @@ | 项目 | 内容 | | --- | --- | | 状态 | 实施中 | -| 版本 | 0.2 | -| 日期 | 2026-08-22 | +| 版本 | 0.3 | +| 日期 | 2026-09-05 | | 适用产品 | GoodBuddy 桌面端 | | 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 | | 相关设计 | [本地文本向量模型与连接设计](./local-embedding-technical-design.md) | @@ -149,7 +149,7 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来 ## 6. 信息架构 -知识工作区继续使用主从布局和现有四个页签: +知识工作区使用主从布局和四个面向用户任务的页签: ```text 知识库 @@ -160,14 +160,21 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来 │ └─ 分块查看与维护 ├─ 知识图谱 ├─ 任务中心 -└─ 设置 +└─ 高级设置 + ├─ 问答效果入口 + ├─ 向量索引 ├─ 检索设置 ├─ 分块设置 └─ 图谱设置 ``` -“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立 -工作台,不新增第五个一级页签。 +“检索测试”是当前知识库的高频验证操作,通过知识库标题区和“高级设置 → +问答效果”打开同一个独立工作台,不新增第五个一级页签。工作台首次打开只突出真实 +问题输入和测试操作;候选倍数、权重、阈值、重排和上下文预算默认折叠。 + +首次使用流程固定为“创建知识库 → 添加资料 → 等待可检索 → 用于当前对话”。创建表单 +默认只展示名称和描述;存储方式与知识图谱属于折叠的高级设置。没有知识库时,唯一主操作 +位于首次使用空状态中。 全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。GoodBuddy 内置连接和用户添加的 OpenAI 兼容连接使用同一连接列表;其界面、数据路径及切换语义以 @@ -195,6 +202,10 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不 2. 检索方式:模型按需检索、每次先检索。 3. 当前范围为空、索引降级或向量未配置时的短说明。 +知识库 ID 选择按 Conversation 持久化。新对话和未保存过知识范围的旧对话默认不选择 +任何知识库;切换对话恢复各自范围,不得在应用启动时自动勾选全部知识库。新建知识库只 +加入当前 Conversation,不重新启用用户已取消的其他知识库。 + ## 7. 第一阶段:可用、可见、可诊断 ### 7.1 检索方式 @@ -296,6 +307,14 @@ Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不 - 失败文档数。 - 当前向量模型与索引是否兼容。 +`indexedDocumentCount` 只统计状态为 `ready` 的文档;`queued`、`parsing` 和 +`indexing` 计入处理中,`failed` 单独计入需处理。文档行持续显示全文、向量和图谱通道 +状态。已有可用分块的失败文档仍允许查看分块,并提供重新处理。 + +文档行可通过受控 `knowledgeBaseId + documentId` 打开数据库中保存的原始来源。 +Renderer 不得提交任意路径。移除来源必须先说明将删除的文档、索引、图谱证据及是否包含 +应用托管副本,用户确认后才执行。 + 降级事件包括: - 未配置向量模型。 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md new file mode 100644 index 00000000..832d4e5b --- /dev/null +++ b/docs/features/knowledge-base/progress.md @@ -0,0 +1,35 @@ +# 知识库实施进度 + +## 已验证实现 + +截至 2026-09-05: + +- 知识库选择按 Conversation 持久化,新对话默认空范围。 +- 创建流程默认只展示名称和描述,高级存储与图谱设置折叠。 +- 知识库汇总区分可检索、处理中和需处理文档。 +- 文档行展示全文、向量和图谱状态,并支持打开原始来源和重新处理失败文档。 +- 移除来源需要确认并说明级联影响。 +- 检索高级参数默认折叠,“高级设置”提供问答效果入口。 +- 向量未配置状态可直接前往模型设置。 +- 有效 DOCX 与多页 PDF 已通过磁盘文件导入、解析、分块和检索生产链路。 + +## 验证证据 + +2026-09-05 最终验证: + +- `npm test`:325 个测试文件、3511 项测试通过;8 个文件、55 项手动或集成测试按既有 + 配置跳过。 +- `npm run typecheck`:通过。 +- `npm run lint`:通过。 +- `npm run build`:Electron Main、Preload、Renderer 生产构建及控制面安装包检查通过。 +- `npx vitest run src/main/knowledge/knowledge-service.test.ts -t "imports real DOCX and PDF files from disk into searchable chunks"`: + 有效 Open XML DOCX 与两页原生文本 PDF 均通过生产 `KnowledgeService.importPaths` + 导入、解析、分块和全文检索。 + +## 剩余工作 + +- 外部 Dify、FastGPT 和 RAGFlow 知识库接入仍为规划中能力。 +- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖 + 原生文本 PDF,不宣称覆盖扫描件 OCR。 +- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受 + 影响。 diff --git a/docs/features/knowledge-base/ui-design.md b/docs/features/knowledge-base/ui-design.md new file mode 100644 index 00000000..d08e9aae --- /dev/null +++ b/docs/features/knowledge-base/ui-design.md @@ -0,0 +1,65 @@ +# 知识库 UI 设计 + +## 1. 设计目标 + +知识库优先帮助普通用户完成“添加资料并可靠提问”,高级 RAG 调试和图谱维护不得阻塞 +首次使用。全局视觉、页面壳层、通知、焦点和危险操作遵循 +[UI-DESIGN.md](../../../UI-DESIGN.md)。 + +## 2. 信息架构 + +```text +知识库 +├─ 知识库列表 +└─ 当前知识库 + ├─ 文档与来源 + ├─ 知识图谱(启用后显示) + ├─ 任务中心 + └─ 高级设置 +``` + +标题区提供“用于当前对话”“检索测试”、编辑和删除。新建知识库是页面主操作。 +“高级设置”承载问答效果入口、向量索引、分块与图谱配置,不把工程参数放在首次路径。 + +## 3. 首次使用 + +没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和 +描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户 +可以导入文件、文件夹或 URL。 + +文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前 +Conversation 的检索范围,返回对话并聚焦输入框。 + +## 4. 状态 + +知识库列表显示“可用”和“需处理”文档数。详情标题持续显示可检索、处理中、需处理和 +总文档数,即使用户填写了知识库描述也不隐藏状态。 + +文档行显示: + +- 总体处理状态。 +- 全文状态。 +- 向量状态。 +- 启用图谱时的图谱状态。 + +失败文档保留错误信息和“重新处理”。只要仍有可用分块,就允许打开分块管理,避免隐藏 +上一版可用索引。 + +## 5. 问答效果与高级参数 + +“检索测试”默认只突出真实问题、测试按钮和结果。高级参数使用折叠区,包含候选倍数、 +通道权重、最低相似度、重排、结果数和上下文预算。“高级设置 → 问答效果”打开同一 +工作台。 + +向量模型未配置时显示“前往设置”,直接进入模型连接,不使用只有说明文字的死路状态。 + +## 6. 来源操作 + +文档行提供“打开来源”,Main 根据知识库和文档归属重新读取保存的文件路径或 URL。 +移除来源使用确认对话框,说明文档数、索引、图谱证据和托管副本影响。初始焦点位于 +取消,提交期间禁止重复操作,失败时保留对话框和错误。 + +## 7. 对话范围 + +知识库多选属于 Conversation。新对话默认空范围;切换对话恢复各自选择。创建新知识库 +只加入当前 Conversation,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。 diff --git a/docs/features/knowledge-base/user-stories.md b/docs/features/knowledge-base/user-stories.md index 03185a1a..d320b5a4 100644 --- a/docs/features/knowledge-base/user-stories.md +++ b/docs/features/knowledge-base/user-stories.md @@ -33,6 +33,18 @@ ## 2. Epic A:明确控制是否检索 +### US-A0 首次建立可用知识库 + +作为首次使用者,我希望先命名知识库并添加资料,不需要先理解向量、分块或图谱。 + +验收: + +- 没有知识库时只显示一个“新建知识库”主操作 +- 创建表单默认只展示名称和描述 +- 存储方式和知识图谱位于折叠的高级设置 +- 文档可检索后提供“用于当前对话” +- 高级检索参数不阻塞首次导入和首次提问 + ### US-A1 模型按需检索 作为普通知识使用者,我希望保留由模型判断是否需要检索的模式,以便一般闲聊 @@ -73,6 +85,9 @@ - 同一请求最多启用 20 个知识库 - 对话输入区持续显示已选数量和检索方式 - 范围为空时检索方式不产生误导状态 +- 知识库 ID 与检索方式一起按 Conversation 持久化 +- 新对话和未保存过范围的旧对话默认不勾选知识库 +- 切换 Conversation 恢复各自选择,不共享临时全局范围 ## 3. Epic B:检索可见、可诊断 @@ -233,6 +248,18 @@ - 全库重建可取消 - 已成功替换的文档继续可用 +### US-D4 查看和移除来源 + +作为知识库维护者,我希望打开原始文档并在移除来源前看到准确影响。 + +验收: + +- 文档行可通过系统默认应用打开数据库保存的原始来源 +- Renderer 只能提交知识库 ID 和文档 ID,不能提交任意路径或 URL +- 移除来源前显示来源名称、文档数量以及索引和图谱证据影响 +- 托管模式额外说明应用托管副本会被删除 +- 用户确认前不得删除来源 + ## 6. Epic E:高级分块 ### US-E1 固定分块 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 7f89b2fb..b70693c6 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -3954,6 +3954,9 @@ describe('AssistantDatabase', () => { provider: 'model', profileId: '00000000-0000-4000-8000-000000000299' }, + knowledgeLibraryIds: [ + '00000000-0000-4000-8000-000000000214' + ], knowledgeRetrievalMode: 'always', title: '发布讨论', updatedAt: 1_775_000_000_000, @@ -4055,6 +4058,9 @@ describe('AssistantDatabase', () => { provider: 'model', profileId: '00000000-0000-4000-8000-000000000299' }, + knowledgeLibraryIds: [ + '00000000-0000-4000-8000-000000000214' + ], knowledgeRetrievalMode: 'always', messages: [ expect.objectContaining({ @@ -4442,6 +4448,9 @@ describe('AssistantDatabase', () => { provider: 'model', profileId: channelDefaultProfileId }, + knowledgeLibraryIds: [ + '00000000-0000-4000-8000-000000000559' + ], knowledgeRetrievalMode: 'always', contextMetrics: { runtimeSelectionKey: `model:${channelDefaultProfileId}`, @@ -4550,6 +4559,9 @@ describe('AssistantDatabase', () => { provider: 'model', profileId: channelDefaultProfileId }, + knowledgeLibraryIds: [ + '00000000-0000-4000-8000-000000000559' + ], knowledgeRetrievalMode: 'always', branch: { sourceConversationId, @@ -4623,6 +4635,7 @@ describe('AssistantDatabase', () => { id: branch.id, projectId: branch.projectId, runtimeSelection: branch.runtimeSelection, + knowledgeLibraryIds: branch.knowledgeLibraryIds, knowledgeRetrievalMode: branch.knowledgeRetrievalMode, branch: branch.branch, title: '发布方案讨论 · 分支(独立更新)', diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 4f00b061..cb99dd22 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1033,6 +1033,7 @@ function interruptActiveToolBlocks( } const conversationContextStateSchema = conversationSnapshotSchema.pick({ + knowledgeLibraryIds: true, contextMetrics: true, contextCompressionState: true }) @@ -1041,7 +1042,7 @@ function parseConversationContextState( value: string | null ): Pick< ConversationSnapshot, - 'contextMetrics' | 'contextCompressionState' + 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' > { if (!value) { return {} @@ -1059,12 +1060,14 @@ function parseConversationContextState( function serializeConversationContextState( conversation: Pick< ConversationSnapshot, - 'contextMetrics' | 'contextCompressionState' + 'knowledgeLibraryIds' | 'contextMetrics' | 'contextCompressionState' > ): string | null { - return conversation.contextMetrics || + return conversation.knowledgeLibraryIds !== undefined || + conversation.contextMetrics || conversation.contextCompressionState ? JSON.stringify({ + knowledgeLibraryIds: conversation.knowledgeLibraryIds, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState }) @@ -1081,9 +1084,9 @@ function toConversationSnapshot( runtimeSelection: parseRuntimeSelection( conversation.runtime_selection_json ), + ...parseConversationContextState(conversation.context_state_json), knowledgeRetrievalMode: conversation.knowledge_retrieval_mode ?? undefined, - ...parseConversationContextState(conversation.context_state_json), ...(conversation.channel && conversation.conversation_type && conversation.account_display @@ -2992,7 +2995,7 @@ export class AssistantDatabase { const source = database .prepare( `SELECT id, project_id, runtime_selection_json, - knowledge_retrieval_mode, title + knowledge_retrieval_mode, context_state_json, title FROM conversations WHERE id = ? AND status = 'active' AND channel IS NULL` ) @@ -3002,6 +3005,7 @@ export class AssistantDatabase { project_id: string | null runtime_selection_json: string | null knowledge_retrieval_mode: 'auto' | 'always' | null + context_state_json: string | null title: string } | undefined @@ -3052,13 +3056,18 @@ export class AssistantDatabase { knowledge_retrieval_mode, context_state_json, work_mode, title, branch_source_conversation_id, branch_source_title, status, created_at, updated_at) - VALUES (?, ?, ?, ?, NULL, 'ask', ?, ?, ?, 'active', ?, ?)` + VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)` ) .run( destinationConversationId, source.project_id, source.runtime_selection_json, source.knowledge_retrieval_mode, + serializeConversationContextState({ + knowledgeLibraryIds: + parseConversationContextState(source.context_state_json) + .knowledgeLibraryIds + }), input.title, source.id, source.title, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 90c59ae3..ad387da3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -74,6 +74,7 @@ import { knowledgeChunkPageSchema, knowledgeChunksListInputSchema, knowledgeChunkUpdateInputSchema, + knowledgeDocumentOpenInputSchema, knowledgeDocumentRebuildInputSchema, knowledgeLibraryRebuildInputSchema, knowledgeReferenceContextInputSchema, @@ -892,6 +893,8 @@ function getKnowledgeSnapshot( sourceCount: library.sourceCount, documentCount: library.documentCount, indexedDocumentCount: library.indexedDocumentCount, + processingDocumentCount: library.processingDocumentCount, + failedDocumentCount: library.failedDocumentCount, retrievalSettings: library.retrievalSettings, chunkingSettings: library.chunkingSettings, chunkingRebuildRequired: library.chunkingRebuildRequired, @@ -926,6 +929,9 @@ function getKnowledgeSnapshot( name: document.title, path: document.sourceLocation, status: document.status, + textIndexStatus: document.textIndexStatus, + vectorIndexStatus: document.vectorIndexStatus, + graphIndexStatus: document.graphIndexStatus, indexProgress: document.status === 'ready' ? 100 : 0, chunkCount: document.chunkCount, size: document.size, @@ -7969,6 +7975,42 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.knowledgeOpenDocumentSource, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const value = knowledgeDocumentOpenInputSchema.parse(input) + const reference = knowledgeService.getDocumentSource(value) + if (!reference) { + throw new Error('文档来源不存在') + } + if (reference.source.type === 'url') { + const target = new URL(reference.source.location) + if (!['http:', 'https:'].includes(target.protocol)) { + throw new Error('文档来源 URL 协议不受支持') + } + await shell.openExternal(target.href) + return + } + const storedPath = + reference.document.sourceLocation ?? reference.source.location + if (!isAbsolute(storedPath)) { + throw new Error('文档来源路径无效') + } + if ((await lstat(storedPath)).isSymbolicLink()) { + throw new Error('文档来源不能是符号链接') + } + const targetPath = await realpath(storedPath) + if (!(await stat(targetPath)).isFile()) { + throw new Error('文档来源不是可打开的文件') + } + const openError = await shell.openPath(targetPath) + if (openError) { + throw new Error('无法打开文档来源') + } + } + ) + registerHandler( ipcChannels.knowledgeReferenceContext, (event, input: unknown) => { diff --git a/src/main/knowledge/knowledge-database.test.ts b/src/main/knowledge/knowledge-database.test.ts index b948473d..1f36250f 100644 --- a/src/main/knowledge/knowledge-database.test.ts +++ b/src/main/knowledge/knowledge-database.test.ts @@ -207,6 +207,51 @@ describe('KnowledgeDatabase', () => { ).toBe(true) }) + it('counts only ready documents as indexed and separates actionable states', async () => { + const { database } = await createDatabase() + const library = database.createKnowledgeBase({ + name: 'Status counts', + storageMode: 'reference' + }) + const source = database.upsertSource({ + knowledgeBaseId: library.id, + type: 'file', + location: 'C:\\notes\\status.md', + displayName: 'status.md', + status: 'ready' + }) + for (const [index, status] of [ + 'ready', + 'queued', + 'parsing', + 'indexing', + 'failed' + ].entries()) { + database.upsertDocument( + { + knowledgeBaseId: library.id, + sourceId: source.id, + externalId: `status-${index}`, + title: `Status ${index}`, + metadata: { status } + }, + [{ + id: `status-${index}-chunk`, + ordinal: 0, + content: `status ${status}` + }] + ) + } + + expect(database.getKnowledgeBaseCounts().get(library.id)).toEqual({ + sourceCount: 1, + documentCount: 5, + indexedDocumentCount: 1, + processingDocumentCount: 3, + failedDocumentCount: 1 + }) + }) + it('normalizes legacy negative vector thresholds without losing settings', async () => { const { database, path } = await createDatabase() const library = database.createKnowledgeBase({ diff --git a/src/main/knowledge/knowledge-database.ts b/src/main/knowledge/knowledge-database.ts index e7ba08c2..4142586c 100644 --- a/src/main/knowledge/knowledge-database.ts +++ b/src/main/knowledge/knowledge-database.ts @@ -846,6 +846,8 @@ export class KnowledgeDatabase { sourceCount: number documentCount: number indexedDocumentCount: number + processingDocumentCount: number + failedDocumentCount: number } > { const rows = this.requireDatabase() @@ -855,8 +857,15 @@ export class KnowledgeDatabase { COUNT(DISTINCT d.id) AS document_count, COUNT(DISTINCT CASE WHEN COALESCE(json_extract(d.metadata, '$.status'), 'ready') - <> 'failed' + = 'ready' THEN d.id END) AS indexed_document_count + ,COUNT(DISTINCT CASE + WHEN json_extract(d.metadata, '$.status') + IN ('queued', 'parsing', 'indexing') + THEN d.id END) AS processing_document_count + ,COUNT(DISTINCT CASE + WHEN json_extract(d.metadata, '$.status') = 'failed' + THEN d.id END) AS failed_document_count FROM knowledge_bases kb LEFT JOIN knowledge_sources s ON s.knowledge_base_id = kb.id LEFT JOIN documents d ON d.knowledge_base_id = kb.id @@ -872,6 +881,14 @@ export class KnowledgeDatabase { indexedDocumentCount: asNumber( row, 'indexed_document_count' + ), + processingDocumentCount: asNumber( + row, + 'processing_document_count' + ), + failedDocumentCount: asNumber( + row, + 'failed_document_count' ) } ]) diff --git a/src/main/knowledge/knowledge-service.test.ts b/src/main/knowledge/knowledge-service.test.ts index ee855b31..1c942950 100644 --- a/src/main/knowledge/knowledge-service.test.ts +++ b/src/main/knowledge/knowledge-service.test.ts @@ -7,6 +7,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { strToU8, zipSync } from 'fflate' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ExtractStructured } from './graph-extractor' import { embeddingStorageProvider } from './embedding-provider-key' @@ -17,6 +18,83 @@ import { UrlImporter } from './url-importer' const temporaryDirectories: string[] = [] const services: KnowledgeService[] = [] +function createRealDocxFixture(): Buffer { + return Buffer.from( + zipSync({ + '[Content_Types].xml': strToU8( + '' + + '' + + '' + + '' + + '' + + '' + ), + '_rels/.rels': strToU8( + '' + + '' + + '' + + '' + ), + 'word/document.xml': strToU8( + '' + + '' + + '' + + '蓝鲸发布流程' + + '发布前必须完成审批和回归测试。' + + '负责人' + + '产品团队' + + '' + ), + 'word/_rels/document.xml.rels': strToU8( + '' + + '' + ) + }) + ) +} + +function createRealPdfFixture(...pageTexts: string[]): Buffer { + const texts = pageTexts.length > 0 ? pageTexts : [''] + const fontObjectId = texts.length + 3 + const firstContentObjectId = fontObjectId + 1 + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + `<< /Type /Pages /Kids [${texts + .map((_, index) => `${index + 3} 0 R`) + .join(' ')}] /Count ${texts.length} >>`, + ...texts.map( + (_, index) => + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + + `/Resources << /Font << /F1 ${fontObjectId} 0 R >> >> ` + + `/Contents ${firstContentObjectId + index} 0 R >>` + ), + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ...texts.map((text) => { + const stream = `BT /F1 14 Tf 72 720 Td (${text}) Tj ET` + return ( + `<< /Length ${Buffer.byteLength(stream)} >>\n` + + `stream\n${stream}\nendstream` + ) + }) + ] + let content = '%PDF-1.4\n' + const offsets = [0] + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(content)) + content += `${index + 1} 0 obj\n${object}\nendobj\n` + } + const xrefOffset = Buffer.byteLength(content) + content += `xref\n0 ${objects.length + 1}\n` + content += '0000000000 65535 f \n' + content += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`) + .join('') + content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` + content += `startxref\n${xrefOffset}\n%%EOF\n` + return Buffer.from(content) +} + async function createService( urlImporter?: UrlImporter, embeddingProvider?: EmbeddingProvider, @@ -48,6 +126,83 @@ afterEach(async () => { }) describe('KnowledgeService', () => { + it('imports real DOCX and PDF files from disk into searchable chunks', async () => { + const { directory, service } = await createService() + const docxPath = join(directory, '蓝鲸发布手册.docx') + const pdfPath = join(directory, 'Blue-Harbor-operations.pdf') + await Promise.all([ + writeFile(docxPath, createRealDocxFixture()), + writeFile( + pdfPath, + createRealPdfFixture( + 'Blue Harbor deployment requires a signed approval.', + 'Rollback begins by restoring the verified package.' + ) + ) + ]) + const library = service.createLibrary({ + name: '真实办公文档', + storageMode: 'reference', + graphEnabled: false + }) + + await service.importPaths(library.id, [docxPath, pdfPath]) + + const snapshot = service.snapshot(library.id) + expect(snapshot.libraries[0]).toMatchObject({ + documentCount: 2, + indexedDocumentCount: 2, + processingDocumentCount: 0, + failedDocumentCount: 0 + }) + expect(snapshot.documents).toHaveLength(2) + expect( + snapshot.documents.every( + (document) => + document.status === 'ready' && + document.textIndexStatus === 'ready' && + document.vectorIndexStatus === 'disabled' + ) + ).toBe(true) + + const docxDocument = snapshot.documents.find( + (document) => document.sourceLocation === docxPath + ) + const pdfDocument = snapshot.documents.find( + (document) => document.sourceLocation === pdfPath + ) + expect(docxDocument).toBeDefined() + expect(pdfDocument).toBeDefined() + expect( + service.getDocumentSource({ + knowledgeBaseId: library.id, + documentId: docxDocument!.id + }) + ).toMatchObject({ + document: { sourceLocation: docxPath }, + source: { knowledgeBaseId: library.id } + }) + const docxChunks = service.database.listChunks(docxDocument!.id) + const pdfChunks = service.database.listChunks(pdfDocument!.id) + expect(docxChunks.map((chunk) => chunk.content).join('\n')).toContain( + '发布前必须完成审批和回归测试' + ) + expect(docxChunks.some((chunk) => chunk.location?.includes('表格'))).toBe( + true + ) + expect(pdfChunks.map((chunk) => chunk.location)).toEqual([ + '第 1 页', + '第 2 页' + ]) + + expect(service.search(library.id, '发布审批')[0]?.document.id).toBe( + docxDocument!.id + ) + expect( + service.search(library.id, 'verified package')[0]?.document.id + ).toBe(pdfDocument!.id) + }) + it('uses document and query embedding roles without exposing providers to runtimes', async () => { const embed = vi.fn(async (input: readonly string[]) => input.map(() => [9, 9]) diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index d0abfeb4..ad7370af 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -30,6 +30,8 @@ import { knowledgeDocumentRebuildInputSchema, knowledgeLibraryRebuildInputSchema, knowledgeReferenceContextInputSchema, + knowledgeDocumentOpenInputSchema, + type KnowledgeDocumentOpenInput, knowledgeRetrieveInputSchema, knowledgeSettingsUpdateInputSchema, type KnowledgeChunkDeleteInput, @@ -109,6 +111,8 @@ export type KnowledgeLibrarySnapshot = KnowledgeBase & { sourceCount: number documentCount: number indexedDocumentCount: number + processingDocumentCount: number + failedDocumentCount: number } export type KnowledgeSourceSnapshot = KnowledgeSource & { @@ -120,6 +124,9 @@ export type KnowledgeSourceSnapshot = KnowledgeSource & { export type KnowledgeDocumentSnapshot = Document & { chunkCount: number status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed' + textIndexStatus: 'waiting' | 'ready' | 'failed' + vectorIndexStatus: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing' + graphIndexStatus: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed' size?: number error?: string } @@ -1067,7 +1074,9 @@ export class KnowledgeService { const counts = libraryCounts.get(library.id) ?? { sourceCount: 0, documentCount: 0, - indexedDocumentCount: 0 + indexedDocumentCount: 0, + processingDocumentCount: 0, + failedDocumentCount: 0 } return { ...library, @@ -1120,6 +1129,21 @@ export class KnowledgeService { : undefined })) const chunkCounts = this.database.getDocumentChunkCounts(libraryId) + const tasks = this.database.listKnowledgeTasks(libraryId) + const library = libraries.find((item) => item.id === libraryId) + const graphTaskByDocument = new Map< + string, + KnowledgeTaskItem + >() + for (const task of tasks) { + if ( + task.documentId && + task.kind === 'graph' && + !graphTaskByDocument.has(task.documentId) + ) { + graphTaskByDocument.set(task.documentId, task) + } + } const documents = libraryDocuments.map((document) => { const status = typeof document.metadata.status === 'string' && @@ -1132,6 +1156,49 @@ export class KnowledgeService { ...document, chunkCount: chunkCounts.get(document.id) ?? 0, status, + textIndexStatus: + status === 'ready' + ? ('ready' as const) + : status === 'failed' + ? ('failed' as const) + : ('waiting' as const), + vectorIndexStatus: (() => { + if (!this.embeddingProvider) { + return 'disabled' as const + } + if (status !== 'ready') { + return 'waiting' as const + } + const state = this.database.getEmbeddingIndexState( + document.id, + embeddingStorageProvider(this.embeddingProvider), + this.embeddingProvider.model + ) + return state?.status === 'ready' + ? ('ready' as const) + : state?.status === 'error' + ? ('failed' as const) + : ('missing' as const) + })(), + graphIndexStatus: (() => { + if (!library?.graphEnabled) { + return 'disabled' as const + } + if (library.graphStrategy === 'ask') { + return 'on-demand' as const + } + if (status !== 'ready') { + return 'waiting' as const + } + const task = graphTaskByDocument.get(document.id) + if (task?.status === 'failed' || task?.status === 'interrupted') { + return 'failed' as const + } + if (task?.status === 'succeeded') { + return 'ready' as const + } + return 'waiting' as const + })(), size: typeof document.metadata.size === 'number' ? document.metadata.size @@ -1150,7 +1217,7 @@ export class KnowledgeService { entities: graph.entities, relations: graph.relations, evidence: graph.evidence, - tasks: this.database.listKnowledgeTasks(libraryId) + tasks } } @@ -1698,6 +1765,25 @@ export class KnowledgeService { } } + getDocumentSource(rawInput: KnowledgeDocumentOpenInput) { + const input = knowledgeDocumentOpenInputSchema.parse(rawInput) + const document = this.database.getDocument(input.documentId) + if ( + !document || + document.knowledgeBaseId !== input.knowledgeBaseId + ) { + return undefined + } + const source = this.database.getSource(document.sourceId) + if ( + !source || + source.knowledgeBaseId !== input.knowledgeBaseId + ) { + return undefined + } + return { document, source } + } + async searchHybrid( knowledgeBaseId: string, query: string, diff --git a/src/preload/index.ts b/src/preload/index.ts index 9b1fea69..7a85fc0f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -149,6 +149,7 @@ import type { KnowledgeChunkPage, KnowledgeChunkUpdateInput, KnowledgeChunksListInput, + KnowledgeDocumentOpenInput, KnowledgeDocumentRebuildInput, KnowledgeLibraryRebuildInput, KnowledgeReferenceContext, @@ -1639,6 +1640,12 @@ const desktopApi: DesktopApi = { ipcChannels.knowledgeRebuildDocument, input ) as Promise, + openDocumentSource: async (input: KnowledgeDocumentOpenInput) => { + await ipcRenderer.invoke( + ipcChannels.knowledgeOpenDocumentSource, + input + ) + }, rebuildLibrary: (input: KnowledgeLibraryRebuildInput) => ipcRenderer.invoke( ipcChannels.knowledgeRebuildLibrary, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index d17952c1..7aa15acf 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -894,6 +894,7 @@ const api: DesktopApi = { graphRelations: [], evidence: [] })), + openDocumentSource: vi.fn(async () => {}), rebuildLibrary: vi.fn(async () => ({ rebuilt: 0, failed: 0 @@ -4741,7 +4742,7 @@ describe('App', () => { }) render() const knowledgeScopeTrigger = await screen.findByRole('button', { - name: '选择知识库,本次已启用 1 个' + name: '选择知识库,本次已启用 0 个' }) expect(knowledgeScopeTrigger).toHaveAttribute( 'aria-haspopup', @@ -4774,6 +4775,7 @@ describe('App', () => { name: /产品知识/u }) await waitFor(() => expect(scopeCheckbox).toHaveFocus()) + fireEvent.click(scopeCheckbox) const composerInput = screen.getByLabelText('向 GoodBuddy 提问') fireEvent.focusOut(scopeCheckbox, { relatedTarget: composerInput @@ -4880,6 +4882,78 @@ describe('App', () => { ) }) + it('restores an independent knowledge scope for each conversation', async () => { + const libraryId = '11111111-1111-4111-8111-111111111111' + const firstConversationId = + '22222222-2222-4222-8222-222222222222' + const secondConversationId = + '33333333-3333-4333-8333-333333333333' + 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: [] + }) + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: firstConversationId, + projectId, + knowledgeLibraryIds: [libraryId], + knowledgeRetrievalMode: 'always', + title: '带知识范围', + updatedAt: 200, + messages: [] + }, + { + id: secondConversationId, + projectId, + knowledgeLibraryIds: [], + knowledgeRetrievalMode: 'auto', + title: '空知识范围', + updatedAt: 100, + messages: [] + } + ]) + + render() + + expect( + await screen.findByRole('button', { + name: '选择知识库,本次已启用 1 个' + }) + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: /^空知识范围/u }) + ) + expect( + screen.getByRole('button', { + name: '选择知识库,本次已启用 0 个' + }) + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: /^带知识范围/u }) + ) + expect( + screen.getByRole('button', { + name: '选择知识库,本次已启用 1 个' + }) + ).toBeInTheDocument() + }) + 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({ @@ -4904,9 +4978,12 @@ describe('App', () => { }) render() const knowledgeScope = await screen.findByRole('button', { - name: '选择知识库,本次已启用 1 个' + name: '选择知识库,本次已启用 0 个' }) fireEvent.click(knowledgeScope) + fireEvent.click( + screen.getByRole('checkbox', { name: /产品知识/u }) + ) const retrievalMode = screen.getByRole('group', { name: '知识检索方式' }) @@ -8410,6 +8487,7 @@ describe('App', () => { provider: 'model', profileId: modelProfileId }, + knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'auto', title: '元数据会话', updatedAt: 1_775_000_000_000, @@ -10591,8 +10669,8 @@ describe('App', () => { fireEvent.click(screen.getByRole('button', { name: '知识库' })) fireEvent.click( await screen.findByRole('button', { - name: /^工程知识 0 个文档/u - }) + name: /^工程知识/u + }, { timeout: 5_000 }) ) expect( await screen.findByText('知识库刷新失败') @@ -10606,7 +10684,7 @@ describe('App', () => { ) expect( screen.getByRole('button', { - name: /^工程知识 0 个文档/u + name: /^工程知识/u }) ).toHaveAttribute('aria-current', 'page') }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 258fa381..3ea6efc2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -767,6 +767,7 @@ function createConversation( id: crypto.randomUUID(), projectId, runtimeSelection, + knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto', title: '新对话', updatedAt: now, @@ -1270,6 +1271,14 @@ function isConversation(value: unknown): value is Conversation { (item.runtimeSelection === undefined || agentRuntimeSelectionSchema.safeParse(item.runtimeSelection) .success) && + (item.knowledgeLibraryIds === undefined || + (Array.isArray(item.knowledgeLibraryIds) && + item.knowledgeLibraryIds.length <= 20 && + item.knowledgeLibraryIds.every( + (libraryId) => + typeof libraryId === 'string' && + assistantIdSchema.safeParse(libraryId).success + ))) && (item.knowledgeRetrievalMode === undefined || item.knowledgeRetrievalMode === 'auto' || item.knowledgeRetrievalMode === 'always') && @@ -1358,6 +1367,7 @@ function toConversationSnapshots( id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, + knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, @@ -1402,6 +1412,7 @@ function toLocalConversationHeader( id: conversation.id, projectId: conversation.projectId, runtimeSelection: conversation.runtimeSelection, + knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, @@ -2672,9 +2683,6 @@ function App(): React.JSX.Element { const failedKnowledgeLibraryIdRef = useRef( undefined ) - const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState< - string[] - >([]) const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false) const knowledgeScopeTriggerRef = useRef(null) const knowledgeScopePopoverRef = useRef(null) @@ -2714,7 +2722,6 @@ function App(): React.JSX.Element { [] ) const hydratingArtifactIds = useRef(new Set()) - const knowledgeScopeInitialized = useRef(false) const inputRef = useRef(null) const [chatScrollSnapshots, setChatScrollSnapshots] = useState< Record @@ -3246,6 +3253,34 @@ function App(): React.JSX.Element { () => conversations.find((conversation) => conversation.id === activeId), [activeId, conversations] ) + const enabledKnowledgeLibraryIds = + activeConversation?.knowledgeLibraryIds ?? [] + const setEnabledKnowledgeLibraryIds = useCallback( + (action: SetStateAction): void => { + setConversations((current) => + current.map((conversation) => { + if (conversation.id !== activeId) { + return conversation + } + const previous = conversation.knowledgeLibraryIds ?? [] + const next = + typeof action === 'function' ? action(previous) : action + if ( + previous.length === next.length && + previous.every((id, index) => id === next[index]) + ) { + return conversation + } + return { + ...conversation, + knowledgeLibraryIds: next, + updatedAt: Date.now() + } + }) + ) + }, + [activeId] + ) const activeProject = useMemo( () => projects.find((project) => project.id === activeProjectId), [activeProjectId, projects] @@ -4017,18 +4052,22 @@ function App(): React.JSX.Element { failedKnowledgeLibraryIdRef.current = undefined setKnowledgeSnapshot(snapshot) setKnowledgeLoadError(undefined) - if (!knowledgeScopeInitialized.current) { - knowledgeScopeInitialized.current = true - setEnabledKnowledgeLibraryIds( - snapshot.libraries.map((library) => library.id) - ) - } else { - setEnabledKnowledgeLibraryIds((current) => - current.filter((id) => - snapshot.libraries.some((library) => library.id === id) - ) - ) - } + const availableIds = new Set( + snapshot.libraries.map((library) => library.id) + ) + setConversations((current) => + current.map((conversation) => { + const previous = conversation.knowledgeLibraryIds ?? [] + const next = previous.filter((id) => availableIds.has(id)) + return previous.length === next.length + ? conversation + : { + ...conversation, + knowledgeLibraryIds: next, + updatedAt: Date.now() + } + }) + ) return snapshot } catch (reason) { if (requestId !== knowledgeLoadRequestRef.current) { @@ -6173,7 +6212,10 @@ function App(): React.JSX.Element { }, [refreshKnowledge]) useEffect(() => { - if (view !== 'knowledge' && knowledgeOperationCount === 0) { + if ( + knowledgeLoadError || + (view !== 'knowledge' && knowledgeOperationCount === 0) + ) { return } const interval = setInterval(() => { @@ -6185,6 +6227,7 @@ function App(): React.JSX.Element { }, knowledgeOperationCount > 0 ? 350 : 1_000) return () => clearInterval(interval) }, [ + knowledgeLoadError, knowledgeOperationCount, knowledgeSnapshot.selectedLibraryId, refreshKnowledge, @@ -8248,7 +8291,6 @@ function App(): React.JSX.Element { graphRelations: [], evidence: [] }) - setEnabledKnowledgeLibraryIds([]) updateAttachments([]) setInput('') setView('chat') @@ -10574,6 +10616,16 @@ function App(): React.JSX.Element { ) ) } + onOpenDocumentSource={(libraryId, documentId) => + window.goodbuddy.knowledge.openDocumentSource({ + knowledgeBaseId: libraryId, + documentId + }) + } + onOpenModelSettings={() => { + setSettingsInitialCategory('model') + setView('settings') + }} onMergeEntities={(sourceId, targetId) => runKnowledgeSourceAction(() => window.goodbuddy.knowledge.mergeEntities( @@ -10606,6 +10658,15 @@ function App(): React.JSX.Element { }).slice(0, 500) }) } + onUseInChat={(libraryId) => { + setEnabledKnowledgeLibraryIds((current) => + current.includes(libraryId) + ? current + : [...current, libraryId] + ) + setView('chat') + requestAnimationFrame(() => inputRef.current?.focus()) + }} onPauseSource={(sourceId) => runKnowledgeSourceAction(() => window.goodbuddy.knowledge.pauseSource(sourceId) diff --git a/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx b/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx index 551d4b6f..9e1db653 100644 --- a/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx +++ b/src/renderer/src/KnowledgeEmbeddingIndexSection.tsx @@ -9,12 +9,14 @@ export type KnowledgeEmbeddingIndexSectionProps = { snapshot?: KnowledgeEmbeddingIndexSnapshot loading?: boolean onRebuild: () => void + onGoToSettings?: () => void onViewTasks?: () => void } export function KnowledgeEmbeddingIndexSection({ snapshot, loading = false, + onGoToSettings, onRebuild, onViewTasks }: KnowledgeEmbeddingIndexSectionProps): React.JSX.Element { @@ -52,6 +54,15 @@ export function KnowledgeEmbeddingIndexSection({
{t('settings.vectorIndex.disabledTitle')}

{t('settings.vectorIndex.disabledDescription')}

+ {onGoToSettings && ( + + )}
) : ( <> diff --git a/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx b/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx index 1decbe34..b12c3ff0 100644 --- a/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx +++ b/src/renderer/src/KnowledgeRetrievalWorkbench.test.tsx @@ -94,6 +94,18 @@ afterEach(async () => { }) describe('KnowledgeRetrievalWorkbench', () => { + const openAdvancedSettings = (): void => { + fireEvent.click(screen.getByText('本次测试参数')) + } + + it('keeps advanced retrieval parameters collapsed by default', () => { + render() + + expect( + screen.getByText('本次测试参数').closest('details') + ).not.toHaveAttribute('open') + }) + it('provides dialog semantics, focuses the persistent query, and returns focus on close', async () => { function Harness(): React.JSX.Element { const [open, setOpen] = useState(false) @@ -159,6 +171,7 @@ describe('KnowledgeRetrievalWorkbench', () => { })} /> ) + openAdvancedSettings() fireEvent.click(screen.getByRole('button', { name: '测试检索' })) @@ -182,6 +195,7 @@ describe('KnowledgeRetrievalWorkbench', () => { })} /> ) + openAdvancedSettings() fireEvent.change(screen.getByLabelText(/^全文占比/), { target: { value: '40' } @@ -208,6 +222,7 @@ describe('KnowledgeRetrievalWorkbench', () => { })} /> ) + openAdvancedSettings() expect(screen.getByLabelText(/^最低向量相似度/)).toHaveValue(0) fireEvent.click(screen.getByRole('button', { name: '测试检索' })) @@ -238,6 +253,7 @@ describe('KnowledgeRetrievalWorkbench', () => { })} /> ) + openAdvancedSettings() expect(screen.getByText(/当前最多召回 24 个融合候选/u)) .toBeInTheDocument() diff --git a/src/renderer/src/KnowledgeRetrievalWorkbench.tsx b/src/renderer/src/KnowledgeRetrievalWorkbench.tsx index 0afc352b..a3c752db 100644 --- a/src/renderer/src/KnowledgeRetrievalWorkbench.tsx +++ b/src/renderer/src/KnowledgeRetrievalWorkbench.tsx @@ -491,7 +491,7 @@ export function KnowledgeRetrievalWorkbench({ )} -
+
{t('retrieval.settings.title')} diff --git a/src/renderer/src/KnowledgeWorkspace.test.tsx b/src/renderer/src/KnowledgeWorkspace.test.tsx index 84b749ee..1f976795 100644 --- a/src/renderer/src/KnowledgeWorkspace.test.tsx +++ b/src/renderer/src/KnowledgeWorkspace.test.tsx @@ -216,6 +216,7 @@ function createProps( onUpdateChunk: vi.fn(), onDeleteChunk: vi.fn(), onRebuildDocument: vi.fn(), + onOpenDocumentSource: vi.fn(), onRebuildLibrary: vi.fn(), onCancelRebuild: vi.fn(), onGetEmbeddingIndex: vi.fn(async () => ({ @@ -245,6 +246,8 @@ function createProps( onCancelTask: vi.fn(), onRetryTask: vi.fn(), onOpenReferenceSource: vi.fn(), + onUseInChat: vi.fn(), + onOpenModelSettings: vi.fn(), onMoveNode: vi.fn(), onCreateEntity: vi.fn(), onUpdateEntity: vi.fn(), @@ -282,6 +285,7 @@ describe('KnowledgeWorkspace', () => { fireEvent.change(screen.getByLabelText('描述'), { target: { value: '访谈与反馈' } }) + fireEvent.click(screen.getByText('高级设置')) fireEvent.click(screen.getByLabelText(/引用原文件/)) expect( screen.getByRole('switch', { name: /启用知识图谱/u }) @@ -343,6 +347,96 @@ describe('KnowledgeWorkspace', () => { ) }) + it('confirms source removal before deleting indexed documents', async () => { + const onRemoveSource = vi.fn() + render( + + ) + + fireEvent.click( + screen.getByRole('button', { name: '移除来源 产品手册' }) + ) + expect( + screen.getByRole('alertdialog', { + name: '移除来源“产品手册”?' + }) + ).toHaveAccessibleDescription( + '将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。' + ) + expect(onRemoveSource).not.toHaveBeenCalled() + fireEvent.click( + screen.getByRole('button', { name: '移除来源' }) + ) + await waitFor(() => + expect(onRemoveSource).toHaveBeenCalledWith('source-1') + ) + }) + + it('opens original documents, exposes index availability, and enters chat', () => { + const onOpenDocumentSource = vi.fn() + const onUseInChat = vi.fn() + render( + + ) + + expect(screen.getByText('全文可用')).toBeInTheDocument() + expect(screen.getByText('向量未启用')).toBeInTheDocument() + expect( + screen.getByText( + '1 篇可检索 · 0 篇处理中 · 0 篇需处理 · 共 1 篇' + ) + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: '打开 架构说明.md 的原始来源' + }) + ) + expect(onOpenDocumentSource).toHaveBeenCalledWith( + 'library-1', + 'document-1' + ) + fireEvent.click( + screen.getByRole('button', { name: '用于当前对话' }) + ) + expect(onUseInChat).toHaveBeenCalledWith('library-1') + }) + + it('lets a failed document be retried while preserving available chunks', () => { + const onRebuildDocument = vi.fn() + const failedDocument = { + ...createProps().documents[0]!, + status: 'failed' as const, + textIndexStatus: 'failed' as const, + vectorIndexStatus: 'failed' as const, + graphIndexStatus: 'failed' as const + } + render( + + ) + + fireEvent.click( + screen.getByRole('button', { + name: '重新处理 架构说明.md' + }) + ) + expect(onRebuildDocument).toHaveBeenCalledWith( + 'library-1', + 'document-1' + ) + expect( + screen.getByRole('button', { name: '文档分块' }) + ).toBeEnabled() + }) + it('opens the retrieval workbench and runs an isolated test query', async () => { const props = createProps() const onRetrieve = vi.fn(props.onRetrieve) @@ -603,12 +697,18 @@ describe('KnowledgeWorkspace', () => { const tabs = screen.getByRole('tablist', { name: '知识库视图' }) expect(within(tabs).getAllByRole('tab').map((item) => item.textContent)) - .toEqual(['文档与来源', '知识图谱', '任务中心', '索引与检索']) + .toEqual(['文档与来源', '知识图谱', '任务中心', '高级设置']) expect( screen.queryByRole('switch', { name: '知识图谱' }) ).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '索引与检索' })) + fireEvent.click(screen.getByRole('tab', { name: '高级设置' })) + expect( + screen.getByRole('heading', { name: '问答效果' }) + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: '测试并调整' }) + ).toBeInTheDocument() expect( await screen.findByRole('heading', { name: '向量索引' }) ).toBeInTheDocument() @@ -627,6 +727,32 @@ describe('KnowledgeWorkspace', () => { }) }) + it('links a disabled vector index to model settings', async () => { + const onOpenModelSettings = vi.fn() + render( + ({ + knowledgeBaseId: library.id, + enabled: false, + coverage: { total: 1, indexed: 0, missing: 1, error: 0 }, + indexStatus: { job: null } + })), + onOpenModelSettings + })} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '高级设置' })) + expect( + await screen.findByText('向量模型未启用') + ).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { name: '前往设置' }) + ) + expect(onOpenModelSettings).toHaveBeenCalledOnce() + }) + it('hides graph navigation and graph-only controls until enabled', () => { const disabledLibrary = { ...library, @@ -640,11 +766,11 @@ describe('KnowledgeWorkspace', () => { const tabs = screen.getByRole('tablist', { name: '知识库视图' }) expect(within(tabs).getAllByRole('tab').map((item) => item.textContent)) - .toEqual(['文档与来源', '任务中心', '索引与检索']) + .toEqual(['文档与来源', '任务中心', '高级设置']) expect(screen.queryByText('本次导入的图谱抽取策略')) .not.toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: '索引与检索' })) + fireEvent.click(screen.getByRole('tab', { name: '高级设置' })) expect(screen.getByRole('switch', { name: /启用知识图谱/u })) .not.toBeChecked() expect( @@ -717,7 +843,7 @@ describe('KnowledgeWorkspace', () => { }) const { rerender } = render() - fireEvent.click(screen.getByRole('tab', { name: '索引与检索' })) + fireEvent.click(screen.getByRole('tab', { name: '高级设置' })) rerender( { /> ) - fireEvent.click(screen.getByRole('tab', { name: '索引与检索' })) + fireEvent.click(screen.getByRole('tab', { name: '高级设置' })) fireEvent.change(screen.getByLabelText('分块方式'), { target: { value: 'parent-child' } }) @@ -1185,7 +1311,7 @@ describe('KnowledgeWorkspace', () => { ) expect( - screen.getByRole('columnheader', { name: '处理状态' }) + screen.getByRole('columnheader', { name: '可用状态' }) ).toBeInTheDocument() expect( screen.queryByRole('columnheader', { name: '索引进度' }) @@ -1317,7 +1443,7 @@ describe('KnowledgeWorkspace', () => { fireEvent.click(mobileBack) expect(workspace).toHaveClass('knowledge-workspace--mobile-list') const selectedLibraryButton = screen.getByRole('button', { - name: /^产品知识 1 个文档/u + name: /^产品知识 1 篇可用 · 0 篇需处理/u }) expect(selectedLibraryButton).toHaveClass( 'knowledge-workspace__library-button--selected' @@ -1895,8 +2021,8 @@ describe('KnowledgeWorkspace', () => { screen.queryByText('建立第一个知识库') ).not.toBeInTheDocument() expect( - screen.getByRole('button', { name: '新建知识库' }) - ).toBeDisabled() + screen.queryByRole('button', { name: '新建知识库' }) + ).not.toBeInTheDocument() }) it('shows a retryable load error instead of the first-library empty state', () => { @@ -1933,7 +2059,9 @@ describe('KnowledgeWorkspace', () => { .toBeInTheDocument() expect(screen.getByText('架构说明.md')).toBeInTheDocument() expect( - screen.getByRole('button', { name: /^产品知识 1 个文档/u }) + screen.getByRole('button', { + name: /^产品知识 1 篇可用 · 0 篇需处理/u + }) ).toHaveAttribute('aria-current', 'page') }) diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index 23a23f6d..fa3ed576 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -15,6 +15,7 @@ import { Link2, ListChecks, LoaderCircle, + MessageSquare, Network, Pencil, Plus, @@ -302,6 +303,10 @@ export type KnowledgeWorkspaceProps = { libraryId: string, documentId: string ) => void | Promise + onOpenDocumentSource: ( + libraryId: string, + documentId: string + ) => void | Promise onRebuildLibrary: (libraryId: string) => void | Promise onCancelRebuild: (libraryId: string) => void | Promise onGetEmbeddingIndex: ( @@ -319,6 +324,8 @@ export type KnowledgeWorkspaceProps = { documentId: string chunkId: string }) => void | Promise + onUseInChat: (libraryId: string) => void + onOpenModelSettings: () => void onMoveNode: ( nodeId: string, position: { x: number; y: number } @@ -690,6 +697,9 @@ function CreateLibraryWizard({

{t('create.title')}

+

+ {t('create.description')} +

+
+ + + {t('create.advanced')} + {t('create.advancedDescription')} + + +
{t('fields.storageMode')} @@ -779,6 +797,8 @@ function CreateLibraryWizard({ )} +
+
{error && (

void + onConfirm: () => void | Promise + source: KnowledgeSource + storageMode: KnowledgeStorageMode +}): React.JSX.Element { + const { t } = useTranslation('knowledge') + const [removing, setRemoving] = useState(false) + const [error, setError] = useState() + const dialogRef = useRef(null) + const cancelRef = useRef(null) + const titleId = useId() + const descriptionId = useId() + + useEffect( + () => activateModalFocus(() => cancelRef.current), + [] + ) + + const remove = async (): Promise => { + setRemoving(true) + setError(undefined) + try { + await onConfirm() + onCancel() + } catch (reason) { + setError(toErrorMessage(reason, t)) + } finally { + setRemoving(false) + } + } + + return ( +

{ + if (event.key === 'Escape' && !removing) { + event.preventDefault() + onCancel() + return + } + trapTabFocus(event, dialogRef.current) + }} + ref={dialogRef} + role="alertdialog" + > +
+
+
+ ) +} + type GraphDestructiveAction = | { kind: 'delete-entity' @@ -1179,9 +1298,11 @@ function DocumentsView({ onImportDirectory, onImportFiles, onImportUrl, + onOpenDocumentSource, onPauseSource, onManageChunks, onRemoveSource, + onRebuildDocument, onRetrySource, onSyncSource, onViewTasks, @@ -1193,8 +1314,10 @@ function DocumentsView({ | 'onImportDirectory' | 'onImportFiles' | 'onImportUrl' + | 'onOpenDocumentSource' | 'onPauseSource' | 'onRemoveSource' + | 'onRebuildDocument' | 'onRetrySource' | 'onSyncSource' | 'sources' @@ -1216,6 +1339,8 @@ function DocumentsView({ const [dragging, setDragging] = useState(false) const [pending, setPending] = useState() const [error, setError] = useState() + const [removingSource, setRemovingSource] = + useState() const [askStrategy, setAskStrategy] = useState>('hybrid') const importGraphStrategy = @@ -1574,9 +1699,7 @@ function DocumentsView({ })} className="danger-button danger-button--quiet" disabled={pending === source.id} - onClick={() => - void run(source.id, () => onRemoveSource(source.id)) - } + onClick={() => setRemovingSource(source)} type="button" >
+ + + {t( + `indexStatuses.text.${ + document.textIndexStatus ?? + (document.status === 'ready' + ? 'ready' + : document.status === 'failed' + ? 'failed' + : 'waiting') + }` + )} + + + {t( + `indexStatuses.vector.${ + document.vectorIndexStatus ?? 'disabled' + }` + )} + + {document.graphIndexStatus !== undefined && ( + + {t( + `indexStatuses.graph.${document.graphIndexStatus}` + )} + + )} + {activeTask && ( {t(taskStageLabelKeys[activeTask.stage])} @@ -1711,8 +1862,49 @@ function DocumentsView({ )} + {document.status === 'failed' && ( + + )} + + + + )} {mode === 'index' && ( void rebuildEmbeddingIndex()} onViewTasks={onViewTasks} snapshot={embeddingIndex} @@ -4096,6 +4321,8 @@ export function KnowledgeWorkspace({ onImportFiles, onImportDirectory, onImportUrl, + onOpenDocumentSource, + onOpenModelSettings, onSyncSource, onPauseSource, onRetrySource, @@ -4113,6 +4340,7 @@ export function KnowledgeWorkspace({ onCancelTask, onRetryTask, onOpenReferenceSource, + onUseInChat, onMoveNode, onCreateEntity, onUpdateEntity, @@ -4279,6 +4507,7 @@ export function KnowledgeWorkspace({
0 ? ( + ) : undefined } description={t('page.description')} headingId="knowledge-workspace-title" @@ -4328,9 +4558,13 @@ export function KnowledgeWorkspace({ {libraries.map((library) => { const selected = library.id === selectedLibrary?.id const libraryMeta = t('workspace.libraryMeta', { - count: formatNumber(library.documentCount, locale), - storageMode: t( - storageModeLabelKeys[library.storageMode] + ready: formatNumber( + library.indexedDocumentCount, + locale + ), + failed: formatNumber( + library.failedDocumentCount ?? 0, + locale ) }) return ( @@ -4430,6 +4664,16 @@ export function KnowledgeWorkspace({ /> ) : !selectedLibrary ? ( setCreating(true)} + type="button" + > +
+