fix: make local knowledge bases ready for daily use
Knowledge selection was shared globally, document readiness overstated usable content, and important import and source-management paths exposed technical details or lacked safe recovery actions. Knowledge scope now persists per conversation, readiness reflects real processing channels, and the workspace guides users from creation and import into cited chat with controlled source actions. Release note: 知识库现按对话保存使用范围,准确区分可用、处理中和失败文档,并提供更简洁的创建、检索测试、来源打开、删除确认和失败重试流程。
This commit is contained in:
@@ -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 和实现契约
|
||||
共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。
|
||||
|
||||
@@ -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 不得提交任意路径。移除来源必须先说明将删除的文档、索引、图谱证据及是否包含
|
||||
应用托管副本,用户确认后才执行。
|
||||
|
||||
降级事件包括:
|
||||
|
||||
- 未配置向量模型。
|
||||
|
||||
@@ -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`;文本、页码定位与检索结果不受
|
||||
影响。
|
||||
@@ -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,不重新启用其他已取消项。发送请求时冻结当前选择和检索方式。
|
||||
@@ -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 固定分块
|
||||
|
||||
@@ -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: '发布方案讨论 · 分支(独立更新)',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
])
|
||||
|
||||
@@ -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(
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
||||
'<Default Extension="xml" ContentType="application/xml"/>' +
|
||||
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
||||
'</Types>'
|
||||
),
|
||||
'_rels/.rels': strToU8(
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
|
||||
'</Relationships>'
|
||||
),
|
||||
'word/document.xml': strToU8(
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
||||
'<w:body>' +
|
||||
'<w:p><w:r><w:t>蓝鲸发布流程</w:t></w:r></w:p>' +
|
||||
'<w:p><w:r><w:t>发布前必须完成审批和回归测试。</w:t></w:r></w:p>' +
|
||||
'<w:tbl><w:tr><w:tc><w:p><w:r><w:t>负责人</w:t></w:r></w:p></w:tc>' +
|
||||
'<w:tc><w:p><w:r><w:t>产品团队</w:t></w:r></w:p></w:tc></w:tr></w:tbl>' +
|
||||
'<w:sectPr/></w:body></w:document>'
|
||||
),
|
||||
'word/_rels/document.xml.rels': strToU8(
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>'
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<KnowledgeSnapshot>,
|
||||
openDocumentSource: async (input: KnowledgeDocumentOpenInput) => {
|
||||
await ipcRenderer.invoke(
|
||||
ipcChannels.knowledgeOpenDocumentSource,
|
||||
input
|
||||
)
|
||||
},
|
||||
rebuildLibrary: (input: KnowledgeLibraryRebuildInput) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.knowledgeRebuildLibrary,
|
||||
|
||||
@@ -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(<App />)
|
||||
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(<App />)
|
||||
|
||||
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(<App />)
|
||||
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')
|
||||
})
|
||||
|
||||
+79
-18
@@ -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<string | undefined>(
|
||||
undefined
|
||||
)
|
||||
const [enabledKnowledgeLibraryIds, setEnabledKnowledgeLibraryIds] = useState<
|
||||
string[]
|
||||
>([])
|
||||
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
|
||||
@@ -2714,7 +2722,6 @@ function App(): React.JSX.Element {
|
||||
[]
|
||||
)
|
||||
const hydratingArtifactIds = useRef(new Set<string>())
|
||||
const knowledgeScopeInitialized = useRef(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [chatScrollSnapshots, setChatScrollSnapshots] = useState<
|
||||
Record<string, ChatScrollSnapshot>
|
||||
@@ -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<string[]>): 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)
|
||||
|
||||
@@ -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({
|
||||
<div className="knowledge-embedding-index__empty">
|
||||
<strong>{t('settings.vectorIndex.disabledTitle')}</strong>
|
||||
<p>{t('settings.vectorIndex.disabledDescription')}</p>
|
||||
{onGoToSettings && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onGoToSettings}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.goToSettings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -94,6 +94,18 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('KnowledgeRetrievalWorkbench', () => {
|
||||
const openAdvancedSettings = (): void => {
|
||||
fireEvent.click(screen.getByText('本次测试参数'))
|
||||
}
|
||||
|
||||
it('keeps advanced retrieval parameters collapsed by default', () => {
|
||||
render(<KnowledgeRetrievalWorkbench {...createProps()} />)
|
||||
|
||||
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()
|
||||
|
||||
@@ -491,7 +491,7 @@ export function KnowledgeRetrievalWorkbench({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className="knowledge-workbench-settings" open>
|
||||
<details className="knowledge-workbench-settings">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>{t('retrieval.settings.title')}</strong>
|
||||
|
||||
@@ -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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ onRemoveSource })}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ onOpenDocumentSource, onUseInChat })}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({
|
||||
documents: [failedDocument],
|
||||
onRebuildDocument
|
||||
})}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({
|
||||
onGetEmbeddingIndex: vi.fn(async () => ({
|
||||
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(<KnowledgeWorkspace {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '索引与检索' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
|
||||
rerender(
|
||||
<KnowledgeWorkspace
|
||||
{...props}
|
||||
@@ -755,7 +881,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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<void>
|
||||
onOpenDocumentSource: (
|
||||
libraryId: string,
|
||||
documentId: string
|
||||
) => void | Promise<void>
|
||||
onRebuildLibrary: (libraryId: string) => void | Promise<void>
|
||||
onCancelRebuild: (libraryId: string) => void | Promise<void>
|
||||
onGetEmbeddingIndex: (
|
||||
@@ -319,6 +324,8 @@ export type KnowledgeWorkspaceProps = {
|
||||
documentId: string
|
||||
chunkId: string
|
||||
}) => void | Promise<void>
|
||||
onUseInChat: (libraryId: string) => void
|
||||
onOpenModelSettings: () => void
|
||||
onMoveNode: (
|
||||
nodeId: string,
|
||||
position: { x: number; y: number }
|
||||
@@ -690,6 +697,9 @@ function CreateLibraryWizard({
|
||||
<h2 className="knowledge-create__title">
|
||||
{t('create.title')}
|
||||
</h2>
|
||||
<p className="knowledge-section-description">
|
||||
{t('create.description')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="knowledge-field">
|
||||
{t('fields.name')}
|
||||
@@ -709,6 +719,14 @@ function CreateLibraryWizard({
|
||||
value={description}
|
||||
/>
|
||||
</label>
|
||||
<details className="knowledge-create__advanced">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>{t('create.advanced')}</strong>
|
||||
<small>{t('create.advancedDescription')}</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="knowledge-create__advanced-content">
|
||||
<fieldset className="knowledge-create__storage">
|
||||
<legend>
|
||||
{t('fields.storageMode')}
|
||||
@@ -779,6 +797,8 @@ function CreateLibraryWizard({
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
{error && (
|
||||
<p
|
||||
aria-live="polite"
|
||||
@@ -1032,6 +1052,105 @@ function DeleteLibraryDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveSourceDialog({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
source,
|
||||
storageMode
|
||||
}: {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
source: KnowledgeSource
|
||||
storageMode: KnowledgeStorageMode
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => cancelRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
setRemoving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await onConfirm()
|
||||
onCancel()
|
||||
} catch (reason) {
|
||||
setError(toErrorMessage(reason, t))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className="knowledge-confirm-dialog-backdrop"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !removing) {
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
trapTabFocus(event, dialogRef.current)
|
||||
}}
|
||||
ref={dialogRef}
|
||||
role="alertdialog"
|
||||
>
|
||||
<section className="knowledge-confirm-dialog">
|
||||
<AlertCircle aria-hidden="true" size={26} />
|
||||
<h2 id={titleId}>
|
||||
{t('documents.removeSource.title', { name: source.name })}
|
||||
</h2>
|
||||
<p id={descriptionId}>
|
||||
{t(
|
||||
storageMode === 'managed'
|
||||
? 'documents.removeSource.managedDescription'
|
||||
: 'documents.removeSource.referenceDescription',
|
||||
{ count: source.documentCount }
|
||||
)}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="knowledge-inline-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="knowledge-confirm-dialog__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={removing}
|
||||
onClick={onCancel}
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={removing}
|
||||
onClick={() => void remove()}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
{removing
|
||||
? t('documents.removeSource.removing')
|
||||
: t('documents.removeSource.action')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [removingSource, setRemovingSource] =
|
||||
useState<KnowledgeSource>()
|
||||
const [askStrategy, setAskStrategy] =
|
||||
useState<Exclude<KnowledgeGraphStrategy, 'ask'>>('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"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
@@ -1671,6 +1794,34 @@ function DocumentsView({
|
||||
>
|
||||
{t(documentStatusLabelKeys[document.status])}
|
||||
</span>
|
||||
<span className="knowledge-document-status__channels">
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.text.${
|
||||
document.textIndexStatus ??
|
||||
(document.status === 'ready'
|
||||
? 'ready'
|
||||
: document.status === 'failed'
|
||||
? 'failed'
|
||||
: 'waiting')
|
||||
}`
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.vector.${
|
||||
document.vectorIndexStatus ?? 'disabled'
|
||||
}`
|
||||
)}
|
||||
</span>
|
||||
{document.graphIndexStatus !== undefined && (
|
||||
<span>
|
||||
{t(
|
||||
`indexStatuses.graph.${document.graphIndexStatus}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{activeTask && (
|
||||
<span className="knowledge-document-status__active">
|
||||
{t(taskStageLabelKeys[activeTask.stage])}
|
||||
@@ -1711,8 +1862,49 @@ function DocumentsView({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={t(
|
||||
'documents.actions.openDocumentSource',
|
||||
{ name: document.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={document.status !== 'ready'}
|
||||
disabled={pending === `open:${document.id}`}
|
||||
onClick={() =>
|
||||
void run(`open:${document.id}`, () =>
|
||||
onOpenDocumentSource(
|
||||
library.id,
|
||||
document.id
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('actions.openSource')}
|
||||
</button>
|
||||
{document.status === 'failed' && (
|
||||
<button
|
||||
aria-label={t(
|
||||
'documents.actions.retryDocument',
|
||||
{ name: document.name }
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={pending === `retry:${document.id}`}
|
||||
onClick={() =>
|
||||
void run(`retry:${document.id}`, () =>
|
||||
onRebuildDocument(
|
||||
library.id,
|
||||
document.id
|
||||
)
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" size={14} />
|
||||
{t('actions.retryDocument')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={(document.chunkCount ?? 0) === 0}
|
||||
onClick={() => onManageChunks(document)}
|
||||
type="button"
|
||||
>
|
||||
@@ -1728,6 +1920,14 @@ function DocumentsView({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{removingSource && (
|
||||
<RemoveSourceDialog
|
||||
onCancel={() => setRemovingSource(undefined)}
|
||||
onConfirm={() => onRemoveSource(removingSource.id)}
|
||||
source={removingSource}
|
||||
storageMode={library.storageMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1992,6 +2192,8 @@ function KnowledgeSettingsView({
|
||||
mode,
|
||||
onCancelRebuild,
|
||||
onGetEmbeddingIndex,
|
||||
onOpenModelSettings,
|
||||
onOpenRetrieval,
|
||||
onRebuildLibrary,
|
||||
onRebuildEmbeddingIndex,
|
||||
onViewTasks,
|
||||
@@ -2002,6 +2204,8 @@ function KnowledgeSettingsView({
|
||||
mode: 'index' | 'graph'
|
||||
onCancelRebuild: KnowledgeWorkspaceProps['onCancelRebuild']
|
||||
onGetEmbeddingIndex: KnowledgeWorkspaceProps['onGetEmbeddingIndex']
|
||||
onOpenModelSettings: KnowledgeWorkspaceProps['onOpenModelSettings']
|
||||
onOpenRetrieval: () => void
|
||||
onRebuildLibrary: KnowledgeWorkspaceProps['onRebuildLibrary']
|
||||
onRebuildEmbeddingIndex:
|
||||
KnowledgeWorkspaceProps['onRebuildEmbeddingIndex']
|
||||
@@ -2220,9 +2424,30 @@ function KnowledgeSettingsView({
|
||||
<div
|
||||
className={`knowledge-settings knowledge-settings--${mode}`}
|
||||
>
|
||||
{mode === 'index' && (
|
||||
<section className="knowledge-settings__section">
|
||||
<div>
|
||||
<h3>{t('settings.retrieval.title')}</h3>
|
||||
<p className="knowledge-section-description">
|
||||
{t('settings.retrieval.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="knowledge-settings__actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={onOpenRetrieval}
|
||||
type="button"
|
||||
>
|
||||
<Search aria-hidden="true" size={15} />
|
||||
{t('settings.retrieval.action')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{mode === 'index' && (
|
||||
<KnowledgeEmbeddingIndexSection
|
||||
loading={embeddingIndexLoading}
|
||||
onGoToSettings={onOpenModelSettings}
|
||||
onRebuild={() => 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({
|
||||
<div className="knowledge-page">
|
||||
<PageHeader
|
||||
actions={
|
||||
libraries.length > 0 ? (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading}
|
||||
@@ -4291,6 +4520,7 @@ export function KnowledgeWorkspace({
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
) : 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 ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setCreating(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
{t('actions.newLibrary')}
|
||||
</button>
|
||||
}
|
||||
description={t('empty.description')}
|
||||
icon={<BookOpen size={34} />}
|
||||
level="page"
|
||||
@@ -4453,27 +4697,49 @@ export function KnowledgeWorkspace({
|
||||
<h2>
|
||||
{selectedLibrary.name}
|
||||
</h2>
|
||||
{selectedLibrary.description && (
|
||||
<p className="knowledge-workspace__description">
|
||||
{selectedLibrary.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="knowledge-workspace__description">
|
||||
{selectedLibrary.description ||
|
||||
t('workspace.librarySummary', {
|
||||
sourceCount: formatNumber(
|
||||
selectedLibrary.sourceCount,
|
||||
locale
|
||||
),
|
||||
indexedCount: formatNumber(
|
||||
selectedLibrary.indexedDocumentCount,
|
||||
locale
|
||||
),
|
||||
documentCount: formatNumber(
|
||||
selectedLibrary.documentCount,
|
||||
locale
|
||||
)
|
||||
})}
|
||||
{t('workspace.librarySummary', {
|
||||
ready: formatNumber(
|
||||
selectedLibrary.indexedDocumentCount,
|
||||
locale
|
||||
),
|
||||
processing: formatNumber(
|
||||
selectedLibrary.processingDocumentCount ?? 0,
|
||||
locale
|
||||
),
|
||||
failed: formatNumber(
|
||||
selectedLibrary.failedDocumentCount ?? 0,
|
||||
locale
|
||||
),
|
||||
documentCount: formatNumber(
|
||||
selectedLibrary.documentCount,
|
||||
locale
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="knowledge-workspace__header-actions"
|
||||
>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={selectedLibrary.indexedDocumentCount === 0}
|
||||
onClick={() => onUseInChat(selectedLibrary.id)}
|
||||
title={t(
|
||||
selectedLibrary.indexedDocumentCount > 0
|
||||
? 'workspace.readyForQuestions'
|
||||
: 'workspace.waitingForDocuments'
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare aria-hidden="true" size={15} />
|
||||
{t('actions.useInChat')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => {
|
||||
@@ -4532,9 +4798,11 @@ export function KnowledgeWorkspace({
|
||||
onImportDirectory={onImportDirectory}
|
||||
onImportFiles={onImportFiles}
|
||||
onImportUrl={onImportUrl}
|
||||
onOpenDocumentSource={onOpenDocumentSource}
|
||||
onManageChunks={openChunkManager}
|
||||
onPauseSource={onPauseSource}
|
||||
onRemoveSource={onRemoveSource}
|
||||
onRebuildDocument={onRebuildDocument}
|
||||
onRetrySource={onRetrySource}
|
||||
onSyncSource={onSyncSource}
|
||||
onViewTasks={(context) => {
|
||||
@@ -4598,6 +4866,13 @@ export function KnowledgeWorkspace({
|
||||
mode="graph"
|
||||
onCancelRebuild={onCancelRebuild}
|
||||
onGetEmbeddingIndex={onGetEmbeddingIndex}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onOpenRetrieval={() => {
|
||||
setRetrievalStatus('idle')
|
||||
setRetrievalError(undefined)
|
||||
setRetrievalResponse(undefined)
|
||||
setRetrievalOpen(true)
|
||||
}}
|
||||
onRebuildLibrary={onRebuildLibrary}
|
||||
onRebuildEmbeddingIndex={onRebuildEmbeddingIndex}
|
||||
onViewTasks={() => {
|
||||
@@ -4627,6 +4902,13 @@ export function KnowledgeWorkspace({
|
||||
mode="index"
|
||||
onCancelRebuild={onCancelRebuild}
|
||||
onGetEmbeddingIndex={onGetEmbeddingIndex}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
onOpenRetrieval={() => {
|
||||
setRetrievalStatus('idle')
|
||||
setRetrievalError(undefined)
|
||||
setRetrievalResponse(undefined)
|
||||
setRetrievalOpen(true)
|
||||
}}
|
||||
onRebuildLibrary={onRebuildLibrary}
|
||||
onRebuildEmbeddingIndex={onRebuildEmbeddingIndex}
|
||||
onViewTasks={() => {
|
||||
|
||||
@@ -23,6 +23,9 @@ export const knowledge = {
|
||||
retry: 'Retry',
|
||||
sync: 'Sync',
|
||||
remove: 'Remove',
|
||||
openSource: 'Open source',
|
||||
retryDocument: 'Process again',
|
||||
useInChat: 'Use in this conversation',
|
||||
saveEntity: 'Save entity',
|
||||
addEntity: 'Add entity',
|
||||
saveRelation: 'Save relation',
|
||||
@@ -80,6 +83,27 @@ export const knowledge = {
|
||||
ready: 'Indexed',
|
||||
failed: 'Processing failed'
|
||||
},
|
||||
indexStatuses: {
|
||||
text: {
|
||||
waiting: 'Full text pending',
|
||||
ready: 'Full text ready',
|
||||
failed: 'Full text unavailable'
|
||||
},
|
||||
vector: {
|
||||
disabled: 'Vectors disabled',
|
||||
waiting: 'Vectors processing',
|
||||
ready: 'Vectors ready',
|
||||
failed: 'Vectors failed',
|
||||
missing: 'Vectors pending'
|
||||
},
|
||||
graph: {
|
||||
disabled: 'Graph disabled',
|
||||
'on-demand': 'Graph on demand',
|
||||
waiting: 'Graph processing',
|
||||
ready: 'Graph ready',
|
||||
failed: 'Graph failed'
|
||||
}
|
||||
},
|
||||
taskKinds: {
|
||||
sourceSync: 'Source sync',
|
||||
documentProcess: 'Document processing',
|
||||
@@ -138,7 +162,12 @@ export const knowledge = {
|
||||
create: {
|
||||
ariaLabel: 'Create library',
|
||||
eyebrow: 'New library',
|
||||
title: 'Create library'
|
||||
title: 'Create library',
|
||||
description:
|
||||
'Name the library, then add files, folders, or web pages and use it in this conversation.',
|
||||
advanced: 'Advanced settings',
|
||||
advancedDescription:
|
||||
'Change these only when you need managed copies or a knowledge graph.'
|
||||
},
|
||||
edit: {
|
||||
ariaLabel: 'Edit library',
|
||||
@@ -526,7 +555,19 @@ export const knowledge = {
|
||||
pauseSource: 'Pause {{name}}',
|
||||
retrySource: 'Retry {{name}}',
|
||||
syncSource: 'Sync {{name}}',
|
||||
removeSource: 'Remove source {{name}}'
|
||||
removeSource: 'Remove source {{name}}',
|
||||
openDocumentSource: 'Open the original source for {{name}}',
|
||||
retryDocument: 'Process {{name}} again'
|
||||
},
|
||||
removeSource: {
|
||||
ariaLabel: 'Confirm source removal',
|
||||
title: 'Remove source “{{name}}”?',
|
||||
managedDescription:
|
||||
'This removes {{count}} documents, their retrieval indexes and graph evidence, plus the app-managed copy. Original files on disk are not changed.',
|
||||
referenceDescription:
|
||||
'This removes {{count}} documents, their retrieval indexes, and graph evidence. Original files on disk are not changed.',
|
||||
action: 'Remove source',
|
||||
removing: 'Removing…'
|
||||
},
|
||||
table: {
|
||||
title: 'Documents and index',
|
||||
@@ -537,7 +578,7 @@ export const knowledge = {
|
||||
document: 'Document',
|
||||
status: 'Status',
|
||||
indexProgress: 'Index progress',
|
||||
processingStatus: 'Processing status',
|
||||
processingStatus: 'Availability',
|
||||
chunks: 'Chunks',
|
||||
size: 'Size',
|
||||
actions: 'Actions'
|
||||
@@ -573,6 +614,12 @@ export const knowledge = {
|
||||
description:
|
||||
'Control how new imports, resyncs, and explicit rebuilds generate entities, relations, and evidence.'
|
||||
},
|
||||
retrieval: {
|
||||
title: 'Answer quality',
|
||||
description:
|
||||
'Test a real question against this library. Advanced retrieval parameters can be adjusted and saved in the test workbench.',
|
||||
action: 'Test and adjust'
|
||||
},
|
||||
chunking: {
|
||||
title: 'Chunking strategy',
|
||||
description:
|
||||
@@ -712,19 +759,23 @@ export const knowledge = {
|
||||
documents: 'Documents and sources',
|
||||
graph: 'Knowledge graph',
|
||||
tasks: 'Task center',
|
||||
settings: 'Index and retrieval'
|
||||
settings: 'Advanced settings'
|
||||
},
|
||||
workspace: {
|
||||
ariaLabel: 'Knowledge workspace',
|
||||
libraryList: 'Library list',
|
||||
libraryListEmpty:
|
||||
'Create a library to manage reusable sources, indexes, and entity relations.',
|
||||
libraryMeta: '{{count}} documents · {{storageMode}}',
|
||||
libraryMeta: '{{ready}} ready · {{failed}} need attention',
|
||||
detailsAriaLabel: 'Library details',
|
||||
tabsAriaLabel: 'Knowledge base views',
|
||||
scopeGlobal: 'Global',
|
||||
librarySummary:
|
||||
'{{sourceCount}} sources, {{indexedCount}}/{{documentCount}} documents indexed.'
|
||||
'{{ready}} searchable · {{processing}} processing · {{failed}} need attention · {{documentCount}} total',
|
||||
readyForQuestions:
|
||||
'Your content is searchable. Add this library to the current conversation and start asking questions.',
|
||||
waitingForDocuments:
|
||||
'Add content and wait for processing before using this library in a conversation.'
|
||||
},
|
||||
loading: {
|
||||
title: 'Loading knowledge base',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const knowledge = {
|
||||
retry: '重试',
|
||||
sync: '同步',
|
||||
remove: '移除',
|
||||
openSource: '打开来源',
|
||||
retryDocument: '重新处理',
|
||||
useInChat: '用于当前对话',
|
||||
saveEntity: '保存实体',
|
||||
addEntity: '新增实体',
|
||||
saveRelation: '保存关系',
|
||||
@@ -76,6 +79,27 @@ export const knowledge = {
|
||||
ready: '索引完成',
|
||||
failed: '处理失败'
|
||||
},
|
||||
indexStatuses: {
|
||||
text: {
|
||||
waiting: '全文待建立',
|
||||
ready: '全文可用',
|
||||
failed: '全文不可用'
|
||||
},
|
||||
vector: {
|
||||
disabled: '向量未启用',
|
||||
waiting: '向量处理中',
|
||||
ready: '向量可用',
|
||||
failed: '向量失败',
|
||||
missing: '向量待建立'
|
||||
},
|
||||
graph: {
|
||||
disabled: '图谱未启用',
|
||||
'on-demand': '图谱按需生成',
|
||||
waiting: '图谱处理中',
|
||||
ready: '图谱可用',
|
||||
failed: '图谱失败'
|
||||
}
|
||||
},
|
||||
taskKinds: {
|
||||
sourceSync: '来源同步',
|
||||
documentProcess: '文档处理',
|
||||
@@ -134,7 +158,10 @@ export const knowledge = {
|
||||
create: {
|
||||
ariaLabel: '创建知识库',
|
||||
eyebrow: '新建知识库',
|
||||
title: '创建知识库'
|
||||
title: '创建知识库',
|
||||
description: '命名后即可添加文件、文件夹或网页,并在当前对话中使用。',
|
||||
advanced: '高级设置',
|
||||
advancedDescription: '仅在需要托管副本或知识图谱时调整。'
|
||||
},
|
||||
edit: {
|
||||
ariaLabel: '编辑知识库',
|
||||
@@ -492,7 +519,19 @@ export const knowledge = {
|
||||
pauseSource: '暂停 {{name}}',
|
||||
retrySource: '重试 {{name}}',
|
||||
syncSource: '同步 {{name}}',
|
||||
removeSource: '移除来源 {{name}}'
|
||||
removeSource: '移除来源 {{name}}',
|
||||
openDocumentSource: '打开 {{name}} 的原始来源',
|
||||
retryDocument: '重新处理 {{name}}'
|
||||
},
|
||||
removeSource: {
|
||||
ariaLabel: '移除来源确认',
|
||||
title: '移除来源“{{name}}”?',
|
||||
managedDescription:
|
||||
'将删除此来源的 {{count}} 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。',
|
||||
referenceDescription:
|
||||
'将删除此来源的 {{count}} 篇文档、检索索引和图谱证据。磁盘上的原始文件不会改变。',
|
||||
action: '移除来源',
|
||||
removing: '正在移除…'
|
||||
},
|
||||
table: {
|
||||
title: '文档与索引',
|
||||
@@ -502,7 +541,7 @@ export const knowledge = {
|
||||
document: '文档',
|
||||
status: '状态',
|
||||
indexProgress: '索引进度',
|
||||
processingStatus: '处理状态',
|
||||
processingStatus: '可用状态',
|
||||
chunks: '分块',
|
||||
size: '大小',
|
||||
actions: '操作'
|
||||
@@ -534,6 +573,12 @@ export const knowledge = {
|
||||
title: '抽取方式',
|
||||
description: '控制新导入、重新同步和显式重建时如何生成实体、关系与证据。'
|
||||
},
|
||||
retrieval: {
|
||||
title: '问答效果',
|
||||
description:
|
||||
'输入真实问题验证当前知识库;高级检索参数可以在测试工作台中调整并保存。',
|
||||
action: '测试并调整'
|
||||
},
|
||||
chunking: {
|
||||
title: '分块策略',
|
||||
description:
|
||||
@@ -666,19 +711,21 @@ export const knowledge = {
|
||||
documents: '文档与来源',
|
||||
graph: '知识图谱',
|
||||
tasks: '任务中心',
|
||||
settings: '索引与检索'
|
||||
settings: '高级设置'
|
||||
},
|
||||
workspace: {
|
||||
ariaLabel: '知识工作区',
|
||||
libraryList: '知识库列表',
|
||||
libraryListEmpty:
|
||||
'创建知识库,集中管理可跨项目使用的来源、索引和实体关系。',
|
||||
libraryMeta: '{{count}} 个文档 · {{storageMode}}',
|
||||
libraryMeta: '{{ready}} 篇可用 · {{failed}} 篇需处理',
|
||||
detailsAriaLabel: '知识库详情',
|
||||
tabsAriaLabel: '知识库视图',
|
||||
scopeGlobal: '全局',
|
||||
librarySummary:
|
||||
'{{sourceCount}} 个来源,{{indexedCount}}/{{documentCount}} 个文档已完成索引。'
|
||||
'{{ready}} 篇可检索 · {{processing}} 篇处理中 · {{failed}} 篇需处理 · 共 {{documentCount}} 篇',
|
||||
readyForQuestions: '资料已可检索,可以加入当前对话开始提问。',
|
||||
waitingForDocuments: '添加资料并完成处理后,即可在对话中使用。'
|
||||
},
|
||||
loading: {
|
||||
title: '正在加载知识库',
|
||||
|
||||
@@ -12525,6 +12525,32 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.knowledge-create__advanced {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.knowledge-create__advanced > summary {
|
||||
padding: var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-create__advanced > summary span,
|
||||
.knowledge-create__advanced-content {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.knowledge-create__advanced > summary small {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.knowledge-create__advanced-content {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
|
||||
.knowledge-create textarea,
|
||||
.knowledge-graph__form textarea {
|
||||
resize: vertical;
|
||||
@@ -13332,6 +13358,20 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.knowledge-document-status__channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.knowledge-document-status__channels > span {
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.knowledge-graph {
|
||||
display: grid;
|
||||
min-height: clamp(500px, calc(100dvh - 260px), 780px);
|
||||
|
||||
@@ -298,6 +298,38 @@ describe('conversation branch contracts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation knowledge scope contracts', () => {
|
||||
it('persists a bounded per-conversation knowledge library selection', () => {
|
||||
const knowledgeLibraryIds = [
|
||||
'00000000-0000-4000-8000-000000000301',
|
||||
'00000000-0000-4000-8000-000000000302'
|
||||
]
|
||||
expect(
|
||||
conversationSnapshotSchema.parse({
|
||||
id: '00000000-0000-4000-8000-000000000303',
|
||||
knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: 'always',
|
||||
title: '知识问答',
|
||||
updatedAt: 1,
|
||||
messages: []
|
||||
}).knowledgeLibraryIds
|
||||
).toEqual(knowledgeLibraryIds)
|
||||
expect(
|
||||
conversationSnapshotSchema.safeParse({
|
||||
id: '00000000-0000-4000-8000-000000000303',
|
||||
knowledgeLibraryIds: Array.from(
|
||||
{ length: 21 },
|
||||
(_, index) =>
|
||||
`00000000-0000-4000-8000-${String(index).padStart(12, '0')}`
|
||||
),
|
||||
title: '知识问答',
|
||||
updatedAt: 1,
|
||||
messages: []
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation persistence contracts', () => {
|
||||
it('preserves complete tool details without persistence length limits', () => {
|
||||
const input = 'input'.repeat(10_000)
|
||||
|
||||
@@ -485,6 +485,7 @@ export const conversationSnapshotSchema = z
|
||||
id: assistantIdSchema,
|
||||
projectId: assistantIdSchema.optional(),
|
||||
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
|
||||
@@ -139,6 +139,7 @@ import type {
|
||||
KnowledgeChunkPage,
|
||||
KnowledgeChunkUpdateInput,
|
||||
KnowledgeChunksListInput,
|
||||
KnowledgeDocumentOpenInput,
|
||||
KnowledgeDocumentRebuildInput,
|
||||
KnowledgeLibraryRebuildInput,
|
||||
KnowledgeReferenceContext,
|
||||
@@ -1477,6 +1478,8 @@ export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & {
|
||||
sourceCount: number
|
||||
documentCount: number
|
||||
indexedDocumentCount: number
|
||||
processingDocumentCount?: number
|
||||
failedDocumentCount?: number
|
||||
retrievalSettings?: KnowledgeRetrievalSettings
|
||||
chunkingSettings?: KnowledgeChunkingSettings
|
||||
chunkingRebuildRequired?: boolean
|
||||
@@ -1505,6 +1508,9 @@ export type KnowledgeDocumentItem = {
|
||||
name: string
|
||||
path?: string
|
||||
status: 'queued' | 'parsing' | 'indexing' | 'ready' | 'failed'
|
||||
textIndexStatus?: 'waiting' | 'ready' | 'failed'
|
||||
vectorIndexStatus?: 'disabled' | 'waiting' | 'ready' | 'failed' | 'missing'
|
||||
graphIndexStatus?: 'disabled' | 'on-demand' | 'waiting' | 'ready' | 'failed'
|
||||
indexProgress?: number
|
||||
chunkCount?: number
|
||||
size?: number
|
||||
@@ -2144,6 +2150,9 @@ export type DesktopApi = {
|
||||
rebuildDocument: (
|
||||
input: KnowledgeDocumentRebuildInput
|
||||
) => Promise<KnowledgeSnapshot>
|
||||
openDocumentSource: (
|
||||
input: KnowledgeDocumentOpenInput
|
||||
) => Promise<void>
|
||||
rebuildLibrary: (
|
||||
input: KnowledgeLibraryRebuildInput
|
||||
) => Promise<{ rebuilt: number; failed: number }>
|
||||
|
||||
@@ -276,6 +276,7 @@ export const ipcChannels = {
|
||||
knowledgeUpdateChunk: 'knowledge:chunk:update',
|
||||
knowledgeDeleteChunk: 'knowledge:chunk:delete',
|
||||
knowledgeRebuildDocument: 'knowledge:document:rebuild',
|
||||
knowledgeOpenDocumentSource: 'knowledge:document:open-source',
|
||||
knowledgeRebuildLibrary: 'knowledge:library:rebuild',
|
||||
knowledgeCancelRebuild: 'knowledge:library:rebuild:cancel',
|
||||
knowledgeEmbeddingIndexGet: 'knowledge:embedding-index:get',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
defaultKnowledgeRetrievalSettings,
|
||||
knowledgeChunkingSettingsSchema,
|
||||
knowledgeChunkUpdateInputSchema,
|
||||
knowledgeDocumentOpenInputSchema,
|
||||
knowledgeRetrievalSettingsSchema,
|
||||
knowledgeRetrieveInputSchema
|
||||
} from './knowledge-contracts'
|
||||
@@ -85,5 +86,21 @@ describe('knowledge contracts', () => {
|
||||
chunkId: 'chunk'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
knowledgeDocumentOpenInputSchema.parse({
|
||||
knowledgeBaseId: 'library',
|
||||
documentId: 'document'
|
||||
})
|
||||
).toEqual({
|
||||
knowledgeBaseId: 'library',
|
||||
documentId: 'document'
|
||||
})
|
||||
expect(
|
||||
knowledgeDocumentOpenInputSchema.safeParse({
|
||||
knowledgeBaseId: 'library',
|
||||
documentId: 'document',
|
||||
path: 'C:\\untrusted.docx'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,6 +315,16 @@ export type KnowledgeDocumentRebuildInput = z.infer<
|
||||
typeof knowledgeDocumentRebuildInputSchema
|
||||
>
|
||||
|
||||
export const knowledgeDocumentOpenInputSchema = z
|
||||
.object({
|
||||
knowledgeBaseId: idSchema,
|
||||
documentId: idSchema
|
||||
})
|
||||
.strict()
|
||||
export type KnowledgeDocumentOpenInput = z.infer<
|
||||
typeof knowledgeDocumentOpenInputSchema
|
||||
>
|
||||
|
||||
export const knowledgeLibraryRebuildInputSchema = z
|
||||
.object({
|
||||
knowledgeBaseId: idSchema
|
||||
|
||||
Reference in New Issue
Block a user