feat: add external knowledge retrieval and cross-project activity

Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only.

Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results.

Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits.

Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes.

Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
This commit is contained in:
mesalogo
2026-09-13 13:10:01 +08:00
parent b84250f62a
commit 3b560c9a5e
102 changed files with 6716 additions and 1484 deletions
+4
View File
@@ -31,6 +31,10 @@ npm start
## 质量验证 ## 质量验证
跨功能测试方法见[质量验证索引](./docs/quality/README.md)。桌面界面的测试分层、
原生键盘、截图与计算样式取证、隔离启动和脱敏报告遵循
[Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md)。
提交或打包前运行: 提交或打包前运行:
```bash ```bash
+3 -1
View File
@@ -282,7 +282,9 @@ otherwise.
pages; the selected model's context window and bounded transports still apply. pages; the selected model's context window and bounded transports still apply.
- [x] **Native Runtime interaction routing**: OpenCode and Continue questions - [x] **Native Runtime interaction routing**: OpenCode and Continue questions
support choices, yes/no, free-text answers, and skipping through the existing support choices, yes/no, free-text answers, and skipping through the existing
question card. OpenCode also routes questions from owned child sessions. question card. Local and managed SSH OpenCode also route questions from
owned child sessions. Managed SSH requires the Agent question adapter;
this does not extend question support to arbitrary ACP services.
Successful answers and skips retain the original questions and answers Successful answers and skips retain the original questions and answers
across rounds and local conversation reloads; answers discarded by older across rounds and local conversation reloads; answers discarded by older
versions cannot be recovered. versions cannot be recovered.
+1 -1
View File
@@ -42,7 +42,7 @@
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择,设置页显示实际管理员或回退模型来源。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,OpenCode 同时转交属于当前请求的子会话提问。回答或跳过成功后保留原问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。 - [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,本机与托管 SSH OpenCode 同时转交属于当前请求的子会话提问。托管 SSH 需要包含问答适配的 Agent,不扩展到任意 ACP 服务。回答或跳过成功后保留原问题与答案,支持多轮记录和本地会话重载;旧版本已丢弃的答案不能恢复。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。 - [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。 - [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
+6
View File
@@ -283,6 +283,9 @@
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。 - 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 项目选择器下方承载跨项目活动汇总,空闲时隐藏;项目计数与活动对话框遵守
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
活动数量不改变项目菜单的稳定分组和顺序。
- 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整, - 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整,
并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于 并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于
`900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。 `900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。
@@ -705,6 +708,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.8 设置中心 ### 13.8 设置中心
- 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
- 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。 - 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。
- 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。 - 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
@@ -805,6 +809,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 15.5 验收 ### 15.5 验收
执行与取证方法见 [Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md),包括真实 Portal、原生键盘、计算样式与 PNG 审阅;视觉 fixture 与完整生产 App 的结果须分别标注。
- [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。 - [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。
- [ ] 在大于等于 `1200px`、`960px`、`720px` 和小于 `720px` 的窗口宽度检查布局。 - [ ] 在大于等于 `1200px`、`960px`、`720px` 和小于 `720px` 的窗口宽度检查布局。
- [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。 - [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。
@@ -1,8 +1,17 @@
# 应用导航与系统工具入口 # 应用导航与系统工具入口
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,应用占用主导航,系统工具从 本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,内置应用通过最底部应用中心
侧栏底部快捷打开助手工作栏。该功能同时为后续插件应用和插件工具预留统一的入口类型,但 访问,并可常驻主导航;系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
不包含插件市场的安装与运行机制。 商店入口、推荐位或安装流程。
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与弹窗见
[活动界面](./ui-design.md#10-项目活动汇总),数据加载与会话跳转见
[技术设计](./technical-design.md#12-项目活动汇总)。此处“活动”指当前运行或待处理会话,
与用于审计的“运行记录”不同;已完成未读面板属于后续范围,本次未实现。
## 文档导航 ## 文档导航
@@ -18,11 +27,15 @@
Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。 Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。
六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为 六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为
演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。 演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。
这些方案用于讨论最底部应用中心;其中商店页、商店应用安装与 F 方案属于未来交互参考,
不纳入首期实现或验收。首期产品只提供内置应用;Demo 暂保留远期示例以便后续讨论。
底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、 底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、
知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名 知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止, 附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。 以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。
这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。 这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用 六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用
上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理” 上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理”
@@ -56,7 +69,7 @@ Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快
| --- | --- | | --- | --- |
| 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 | | 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 |
| 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 | | 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 |
| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,从侧栏底部打开助手工作栏中的对应面板 | | 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,由右侧助手工作栏 Tab 承载 |
| 插件应用 | 未来由插件提供的一级页面入口 | | 插件应用 | 未来由插件提供的一级页面入口 |
| 插件工具 | 未来由插件提供的辅助面板或命令入口 | | 插件工具 | 未来由插件提供的辅助面板或命令入口 |
| 快捷入口 | 只负责打开或聚焦能力,不复制该能力的界面和业务状态 | | 快捷入口 | 只负责打开或聚焦能力,不复制该能力的界面和业务状态 |
@@ -166,3 +166,23 @@ warming/loading/live/partial/error -> inactive
- 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。 - 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。
这些决策不影响首期四个系统工具的固定入口和内置应用显隐实现。 这些决策不影响首期四个系统工具的固定入口和内置应用显隐实现。
## 9. 项目活动汇总
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按 Conversation ID 聚合,每条会话最多一行。
数据来源为活动会话 ID、会话中的流式消息及待回答/待审批请求,以及全部可见的运行中或
等待处理 Task;不能只取当前项目、当前搜索结果或每条会话的最新 Task。
| 优先级(高到低) | 条件 | 汇总类别 |
| --- | --- | --- |
| 待回答 | 消息有待回答问题 | 待处理 |
| 待审批 | 消息有待审批请求 | 待处理 |
| Task 等待处理 | Task 为 `waiting_approval`,尚无更具体的消息状态 | 待处理 |
| 运行中 | 活动会话 ID、`streaming` 消息或运行中 Task | 运行中 |
同一会话只采用最高优先级状态;Task 的 `waiting_approval` 也可能对应提问,不能直接
标成待审批。“待处理”和“运行中”数量互斥,全局总数为两者之和;项目计数使用相同结果。
项目归属优先取会话元数据,缺少会话时取 Task 元数据;无项目归属的活动只计入全局汇总。
历史排队项、仅待调度的 Task、终态 Task 和未读标记本身不构成活动。活动结束且没有其他
运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。
@@ -12,6 +12,9 @@
## 1. 背景 ## 1. 背景
计划修订说明:当前实施范围以[第 8 节](#8-分期)为准。本文其余章节仍包含 v0.2 的
左下角系统工具方案;启动实现前须同步功能要求、验收及配套设计,旧快捷工具条不再列入实施。
主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组, 主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端 底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端
和浏览器则需要先找到右侧助手工作栏后才能打开。 和浏览器则需要先找到右侧助手工作栏后才能打开。
@@ -48,7 +51,8 @@
## 4. 非目标 ## 4. 非目标
- 首期不提供第三方插件搜索、下载、安装、更新、签名验证或卸载。 - 首期不提供应用商店页面、商店导航、推荐位或安装按钮,也不提供第三方插件搜索、下载、
安装、更新、签名验证或卸载。内置应用的浏览、搜索与打开属于应用中心,不依赖商店。
- 不把文件树、终端或浏览器重新实现到左侧栏。 - 不把文件树、终端或浏览器重新实现到左侧栏。
- 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。 - 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。
- 不允许隐藏运行记录、当前对话或设置。 - 不允许隐藏运行记录、当前对话或设置。
@@ -135,6 +139,17 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时 窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时
收起文字和非必要分隔,不隐藏四个系统工具或设置。 收起文字和非必要分隔,不隐藏四个系统工具或设置。
### FR-14 项目活动汇总
用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。
没有活动时隐藏汇总入口;项目菜单保留原有分组与顺序,并显示各项目的活动数量。
汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见
[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。
验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的
会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。结束后仅有未读标记的
会话不保留在活动汇总中;已完成未读面板不属于本项交付。
## 6. 权限与安全 ## 6. 权限与安全
- 快捷入口不授予新的文件、命令、网络或浏览器权限。 - 快捷入口不授予新的文件、命令、网络或浏览器权限。
@@ -163,15 +178,44 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
### 首期 ### 首期
- 重构侧栏底部,增加四个系统工具按钮和独立设置按钮。 - 将侧栏最底部改为内置应用中心入口,设置按钮保持独立;具体列表、宫格或卡片样式从
- 建立从 App 到助手工作栏的“打开或聚焦工具”意图接口。 Demo 中收敛确定。
- 增加本机资源快照采集与资源监控单实例面板。 - 应用中心提供魔法笔记、知识库、智能心跳的浏览、搜索与打开,不要求安装。
- 增加内置应用的主导航显示设置。 - 支持应用常驻主导航、取消常驻和排序;未常驻应用仍可从应用中心打开,隐藏不删除数据或停止后台任务。
- 应用中心和应用页面就近提供已有功能设置,共享真实设置来源;已有全局设置入口定位到同一界面。
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
- 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。
- 保持运行记录为固定系统入口。 - 保持运行记录为固定系统入口。
### 后续 首期验收需覆盖:无商店入口时可以访问全部内置应用;取消常驻后仍可重新打开和配置;
不同设置入口状态一致;常驻排序重启后保留;右侧工具切换不改变当前应用页面。
本节是范围调整,Demo 交互通过不代表正式功能已经实现。
- 插件应用和插件工具注册。 ### 补充要求:魔法笔记迁移与入口联动
- 插件工具固定、取消固定和排序。
- 插件市场、权限详情、安装和卸载。 - 将现有魔法笔记设置迁入应用中心的应用设置界面,笔记页面内的设置入口访问同一份配置。
魔法笔记可常驻主导航;原全局设置位置如保留入口,应定位到共同设置界面。
- 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。
AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。
新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。
- 应用关闭后的入口联动必须覆盖主导航和会话页面,不能只更新应用中心卡片或一个菜单。
知识库关闭后,左侧知识库菜单及会话中的知识库入口按钮同步移除;重新开启后按配置恢复。
- “取消常驻”只移除主导航捷径;“关闭应用”移除该应用在主导航、会话等业务界面的入口。
应用中心保留管理和重新开启入口。原型中仅有常驻控制,正式设计需补齐这两种状态,避免混用。
- 入口变化不删除已有笔记或知识数据。知识库已打开页面、会话已关联知识以及后台任务的处理,
在详细逻辑同步时分别定义,不能仅凭隐藏按钮推断功能或后台行为已经停用。
补充验收:从任一配置入口关闭知识库,主导航和当前会话按钮立即同步移除,切换会话、
重启后也不重新出现;重新开启后恢复相应入口。验证魔法笔记未配置时的默认开启,以及
已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。
### 后续:应用商店与扩展
- 单独设计应用商店的目录、搜索、推荐、详情及安装管理,时间与具体范围另行确定。
- 插件应用和插件工具注册,以及安装、更新、卸载与权限说明。
- 插件应用可常驻主导航;插件工具按工作栏扩展设计接入,不恢复左下角重复系统工具条。
- 用户级快捷键与命令面板接入同一贡献命令。 - 用户级快捷键与命令面板接入同一贡献命令。
现有 Demo 的商店页、项目管理和阅读空间安装流程仅保留为未来交互参考,不纳入首期验收,
也不作为已有商店能力的说明。首期实现不预建插件安装服务或远程目录协议。
@@ -371,13 +371,38 @@ resourceMonitor.getLocalSnapshot(input: {
## 11. 实施顺序 ## 11. 实施顺序
1. 增加应用导航设置 schema、迁移和设置界面。 实施范围以 [PRD 分期](./prd.md#8-分期)为准。本节替代旧实施顺序;前文关于四个左侧
2. 按即时保存结果组合主导航,并补充失败恢复。 系统工具按钮、仅持久化隐藏 ID 的契约需在实现前同步,不能直接按旧规格开发。
3. 增加资源快照契约、Main 采集服务和 Preload API。
4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。
5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。
6. 为工作栏增加带上下文的 `openOrActivate` 意图接口,以及浏览器和终端匹配规则。
7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。
8. 完成 Renderer、契约、迁移和跨平台回归测试。
插件注册与插件市场不与首期改动一并实施。 1. 同步 PRD 功能要求、User Stories、逻辑、UI 与技术契约,收敛首期应用中心样式。
2. 接入现有内置应用页面与最底部应用中心入口,保留独立设置和固定运行记录入口。
3. 为常驻显隐和排序设计最小持久化契约,保留已发布的相关设置与用户数据,按保存结果更新导航。
4. 接入应用设置的共同界面与既有设置更新路径,先验证魔法笔记多入口状态一致性。
5. 验证右侧现有文件、终端、浏览器 Tab;移除计划中的左下角工具快捷条和专用于该快捷条的意图接口工作。
6. 单独实现资源快照契约、Main 采集服务、Preload API 与右侧 `resources` 单实例面板。
7. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。
首期使用内置应用清单,不依赖远程商店、插件注册或安装服务。商店目录、推荐、安装与
更新机制在后续独立设计中确定;Demo 中的模拟商店不接入首期产品界面。
## 12. 项目活动汇总
对应 [FR-14](./prd.md#fr-14-项目活动汇总),按[活动逻辑](./logic-design.md#9-项目活动汇总)
派生状态,界面行为见[活动界面](./ui-design.md#10-项目活动汇总)。
- `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见
`assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。
`ProjectActivity` 渲染汇总与弹窗,`ProjectSwitcher` 复用项目计数。
- 持久化快照刷新时,同一条仍在流式输出的消息保留本地待审批、待回答信息,持久化终态
则清除这些信息。响应成功后对应 Task 立即退出等待状态;响应期间到达的新问题、审批或
终态不能被旧响应覆盖。
- `AssistantDatabase.listTasks()` 保留最近历史窗口(默认 100 条),额外包含窗口外当前
运行或等待处理的可见 Task;`activeVisibleTaskSelect` 与 `toTask` 的有效状态保持一致,
待调度运行规范化为 `queued`,不因旧 Task 状态被误纳入实时集合。
- `listConversations()` 保留最近 100 条活动会话,同时包含上述 Task 关联的较早活动会话,
以及含 `streaming` 消息的较早活动会话。两类列表去重后仍按原时间顺序返回,结果允许超过
历史窗口大小,避免后台工作被最近记录挤出。
- `openActivityConversation` 按确切 Conversation ID 导航;内存中缺失时刷新持久化会话
列表并合并,再查找同一 ID。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。
设置离开检查通过后才提交项目、会话及界面清理状态。
- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。
@@ -19,6 +19,7 @@
```text ```text
品牌 品牌
项目选择 项目选择
项目活动汇总(有活动时)
新对话 新对话
搜索 搜索
@@ -202,3 +203,21 @@ CPU 4% · 内存 620 MB · 桌面进程 7
插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置 插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置
保持保留,不向插件开放。 保持保留,不向插件开放。
## 10. 项目活动汇总
对应 [FR-14](./prd.md#fr-14-项目活动汇总),状态与计数遵守
[活动逻辑](./logic-design.md#9-项目活动汇总)。
- 全局汇总按钮位于项目选择器下方,上行标明“全项目活动”,下行显示待处理与运行中数量;
箭头跨两行居中,无活动时整个按钮隐藏。
- 项目菜单在项目行显示同口径数量,保留“本地项目 / 远程项目 / 远程通道”分组和 SSH Host
层级,不因活动状态重排项目。数量为零时不显示标记,连接状态仍独立显示。
项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。
- 点击汇总打开活动对话框,先列“待处理”,再列“运行中”,空分组隐藏。每行显示项目名称、
会话标题及明确状态文字,不展开 Task、Job 或 Run 列表,也不自动切换用户正在查看的会话。
- 点击一行先关闭对话框、释放 Modal 焦点隔离,再请求进入该条会话;允许离开当前页面后,
选择所属项目、清除搜索和该会话未读标记、清除任务选择与会话操作菜单,窄窗口同时关闭侧栏。
设置页沿用未保存修改的离开检查,取消离开时保留原页面。
- 对话框沿用全局 Modal 规范,支持 Tab 循环、Escape、关闭按钮、点击遮罩关闭及焦点恢复;
窄窗口限制宽高并在内容区滚动。活动全部结束时关闭对话框并隐藏入口。
@@ -1,5 +1,62 @@
# 工作栏实现与验证进度 # 工作栏实现与验证进度
## 2026-09-13 托管 SSH 原生问答
- 当前源码支持前台托管 SSH OpenCode 的父、子孙会话原生问题、回答和拒答,复用已有
Renderer 队列与 Task 归属。问题来源为原生 `question.asked`;模型正文和普通工具活动
不生成问答表单。协议、身份与生命周期规则见
[远程主机技术设计](../remote-host/technical-design.md#acp-与断线)。
- 新增 `remote-question-binary.test.ts`,使用真实 OpenCode `1.18.29` ACP 进程、生产插件、
Agent Prompt owner 和 loopback 模型夹具,覆盖父问题、Task 子问题、拒答、取消、
跨 binding 回答拒绝、迟到回答拒绝,以及临时回复能力不进入语义记录。
插件测试另覆盖孙会话归属、并发重复事件、HTTP 路径认证、原生待答列表校验和端口关闭;
Runtime 测试覆盖已 ACK 记录后的待答补发、回复失败重试与已回答历史去重。
- 共享 Linux x64 Host 使用既有加密凭据和固定 Host Key,经真实 SSH attach 启动当前源码
的隔离 Agent。生产 `createManagedRemoteAcpRuntime` 入口完成 Ask、Execute 并行父子
问答、拒答和待答期间取消;两个并行问题在首次回答前均到达 Desktop Runtime,子问题
带 Task 归属,回答后原生 Task 和父轮次正常完成。拒答结束原生轮次,取消后迟到回答
明确失败。最终一轮 7 次确定性模型夹具请求,外部模型调用 0 次;测试 daemon 停止,
测试目录与上传文件已清理,未修改 Host 原有 Runtime registry。
- 最终定向回归:11 份测试文件,219 项通过、5 项平台跳过,包含 Agent、模型桥、ACP
Runtime、认证控制通道及托管入口测试。`npm run typecheck`、`npm run lint` 通过。
本轮全量 `npm test -- --reporter=json --outputFile=<临时报告>` 为 4,081 项通过、
0 项失败、66 项跳过;之后的插件身份与并发去重补充由最终定向回归和 Host 复测覆盖。
- 当前验证未操作 Electron 窗口、重装共享 Agent 或模拟整机重启;问答队列的界面行为由
既有 App/ChatTimeline 测试覆盖。同一存活 Prompt 的待答补发由 Runtime 回归验证,
不把它描述成 Agent 进程重启后的问题恢复。旧 Agent 和任意第三方 ACP 服务的范围见
[Runtime 交互边界](./runtime-interactions.md)。
## 2026-09-13 并行提问顺序回答
- 修复同一前台请求的后续提问覆盖未回答问题:Renderer 按 ID 保存待答队列,逐次显示、
回答和跳过,保留失败重试输入;运行中快照刷新保留队列,终态清理全部待答项。
交互和归属限制见 [Runtime 交互边界](./runtime-interactions.md#运行与失败)。
- 本机 SDK 使用已收到的 Task 子会话元数据传递可选 `childTaskId`;Task 卡片显示待答状态,
展开后可定位并聚焦队首表单。没有已知 Task 关联的问题仍能回答。
- 六份相关测试文件共 346 项通过:`App.test.tsx`、`ChatTimeline.test.tsx`、
`conversation-activity.test.ts`、`opencode-runtime.test.ts`、
`opencode-runtime-permissions.test.ts`、`opencode-runtime-lifecycle.test.ts`。
覆盖并发到达、重复 ID、草稿保留、提交失败、队列推进、快照刷新及终态与回复竞态。
- 真实本机 OpenCode 二进制配合 loopback 模型服务验证两次原生提问均在首次回答前到达,
随后分别回传并完成请求;同时保留 Task 文件读取和 Ask 工具限制验证。该用例每轮
6 次 loopback 模型请求,外部模型调用 0 次。完整桌面点击路径由 App 组件测试覆盖,
本轮未手动操作 Electron 窗口。
- 首次 `npm test` 达到工具 200 秒上限;后台重跑 `npm test -- --reporter=verbose` 完成,
348 份测试文件通过、9 份跳过,4,069 项通过、66 项跳过,耗时 572.20 秒。
随后补充已回答 ID 的去重必须先于任务状态更新,最终 18 项问答及活动相关回归通过。
- 独立评审补充同一 React 批次首次两次收到相同 ID 的回归:修复前显示两个待答项。
在消息函数式更新器内复核待答和已回答 ID,重复事件直接返回原消息,保留状态;外层
去重仍阻止重复事件改写任务状态。修复后 19 项定向回归、typecheck 和完整 lint 通过。
- 最终 App 全文件复跑为 217 项通过、1 项失败;失败项为并行开发中的外部知识绑定测试
`adds a new external binding to the current selection without re-enabling an excluded library`,
“添加知识库”按钮未启用,定向复跑仍失败。本次未修改该测试或知识绑定逻辑。
`npm run typecheck` 与差异空白检查通过;早期完整 lint 通过,最终完整 lint 扫到并行
工作新增的 `.kb-app-live` 产物而失败。保持产物原样,使用
`npm run lint -- --ignore-pattern ".kb-app-live/**" --ignore-pattern ".kb-app-live*.cjs" --ignore-pattern ".kb-app-live*.ts"`
复跑通过。
- 远程 `AcpRemoteRuntime` 仅接入权限请求,尚无业务问答桥;本次未修改 Agent、ACP
传输、共享子任务进度适配器或 SSE 订阅,不将本机问答验证算作远程覆盖。
## 2026-09-13 浏览器租用标签页关闭与显式恢复 ## 2026-09-13 浏览器租用标签页关闭与显式恢复
- 更新 `tests/browser-tabs-electron-e2e.ts`,移除两处租用期间拒绝关闭的旧断言。 - 更新 `tests/browser-tabs-electron-e2e.ts`,移除两处租用期间拒绝关闭的旧断言。
@@ -5,22 +5,34 @@
| Runtime | 权限确认 | 业务问答 | | Runtime | 权限确认 | 业务问答 |
| --- | --- | --- | | --- | --- | --- |
| 本机 GoodBuddy 管理的 OpenCode | 内部默认配置允许权限;每个 Ask 请求仍使用 deny-all 与显式只读能力。Execute 自动回复属于当前请求的父、子孙会话权限事件,不处理其他并行请求 | 通过 `question.asked` 转交父、子孙会话问题,支持原生单选、多选、yes/no、自由文本及跳过;使用公开 ID 回传 `question.reply/reject` | | 本机 GoodBuddy 管理的 OpenCode | 内部默认配置允许权限;每个 Ask 请求仍使用 deny-all 与显式只读能力。Execute 自动回复属于当前请求的父、子孙会话权限事件,不处理其他并行请求 | 通过 `question.asked` 转交父、子孙会话问题,支持原生单选、多选、yes/no、自由文本及跳过;使用公开 ID 回传 `question.reply/reject` |
| 托管 SSH OpenCode | Agent 按已接受 Prompt 的 Ask/Execute 模式处理 ACP 权限,规则见远程主机技术设计 | 当前 Agent 插件转交原生父、子孙会话 `question.asked`;前台使用同一问答队列,回答和跳过经认证 Agent 控制通道回传,已知 Task 关联显示子任务归属 |
| Continue | Execute 启动传入 `--auto`,剩余原生权限请求按当前模式自动答复 | 对接 `1.5.47` QuizService 的扁平 `pendingQuestion`,其中 `question` 是字符串。选项回答与自定义文本分别回传正确的 `isCustomAnswer`;跳过发送拒绝回答的文本 | | Continue | Execute 启动传入 `--auto`,剩余原生权限请求按当前模式自动答复 | 对接 `1.5.47` QuizService 的扁平 `pendingQuestion`,其中 `question` 是字符串。选项回答与自定义文本分别回传正确的 `isCustomAnswer`;跳过发送拒绝回答的文本 |
| DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 | | DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 |
托管 SSH OpenCode 的 ACP 权限处理与直接启动配置由 托管 SSH OpenCode 的权限、原生问题事件与回复链路由
[远程主机技术设计](../remote-host/technical-design.md)定义;本机 SDK 问答适配不代表远程 [远程主机技术设计](../remote-host/technical-design.md)定义。远端业务问答需要包含此适配的
ACP 已提供相同业务问答接口。 Agent 和新启动的托管 Runtime;任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。
## 运行与失败 ## 运行与失败
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。 - 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
- 前台问题停留在现有问答卡片,保留回答、提交失败提示与跳过入口。 - 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的
`pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复
事件不替换当前表单或清空输入,回答或跳过成功后只移除对应 ID,再显示下一张表单。
提交失败保留队列、输入和重试入口,队列未清空时继续显示“等待你的回答”。
- 本机与托管 SSH OpenCode 在提问到达前已收到原生 Task 的 `metadata.sessionId` 时,将精确匹配的
子会话关联为可选 `childTaskId`。问答卡片显示该任务名称;对应 Task 卡片显示
“等待你的回答”,展开后可点击“前往问答表单”滚动并聚焦当前队首表单,仍按顺序回答。
没有已知关联的子孙会话问题照常排队,不猜测子任务归属,也不从工具名或进度文本构造表单。
- 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、 - 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、
自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。 自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。
提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。 提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。
- 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时 - 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时
不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。 不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。
- 待答队列仅保存在当前桌面会话内存中,不写入会话快照。刷新仍在运行的消息时保留整个
本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存
已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH
重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。 - 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。 - 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应 - 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
@@ -86,6 +86,40 @@ type BuiltinModelToolSummary = {
- 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读 - 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读
位置,搜索截断时要求模型缩小路径、glob 或表达式。 位置,搜索截断时要求模型缩小路径、glob 或表达式。
### 读取与搜索失败恢复
`workspace_read_text` 和 `workspace_rg` 的输入校验及预期工作区错误由 `ModelToolProvider`
转为 `RecoverableModelToolError`,保留原始 cause。范围包括 schema 校验失败、绝对路径或
越界路径、目标不存在或类型不符、无效 UTF-8、无效读取范围,以及 `ENOENT`、`ENOTDIR`、
`EISDIR`、`EACCES`、`EPERM`、`ELOOP`、`ENAMETOOLONG` 等已列明的文件系统错误。
Runtime 向模型返回 `{ ok: false, recoverable: true, error, nextAction }` 的 JSON 文本工具结果,
工具活动记为 `recoverable`,继续工具循环,由模型修正路径、分页参数、glob 或搜索表达式;
提示不得原样重复失败参数。取消、远端断线、失效工作区、缺失 rg 可执行文件及未分类的内部
错误继续向外传播,不转为可恢复结果。
读取和搜索仍只接受工作区内相对路径,绝对路径即使指向工作区内文件也拒绝;原有越界及
符号链接检查继续生效。仅当本机直连模型 Execute 的 `process_execute` 实际可用时,恢复
提示才建议用它访问工作区外文件。此处理不改变 Windows 用户权限、UAC 或文件 ACL,
也不提升进程权限;Ask 仍保持只读。
ripgrep 结果按退出状态处理:
- exit 1 保持正常无匹配结果。
- exit 2 有 stdout 且不是 regex/glob 编译错误时,保留已取得的匹配或文件列表,附加
`incomplete search coverage` 警告及有界 stderr。零匹配只代表已搜索部分,不能据此断言
整个目标不存在匹配;输出截断提示仍独立保留。
- exit 2 无 stdout,或出现无效 regex/glob 编译错误时,搜索层抛错,再由 Provider 转为
上述可恢复结果;模型可修正表达式,字面搜索可使用 `fixedStrings`。
该契约属于桌面 `ModelAgentRuntime → ModelToolProvider` 内置工具路径,复用浏览器标签页
关闭和元素引用失效已有的可恢复错误机制;浏览器分别提示 `browser_navigate` 或
`browser_snapshot`,路由规则见[浏览器技术设计](../assistant-workbar/browser-tabs-technical-design.md#61-兼容阶段)。
`workspace_read_text` 若使用 `RemoteWorkspaceAccess`,仍经同一 Provider 分类远端
`data.code` 中的预期文件系统和路径/文本错误。Agent daemon 的 RPC 错误序列化保留
`ENOENT` 等大写系统错误码,避免远端文件不存在被误判为不可恢复错误;远端原生工具
处理不变。内置 rg 仍仅支持本机工作区,托管 SSH 当前仍使用远端 OpenCode 原生工具,
不因共享读取抽象而开放远端直连模型,见[远程 Runtime 边界](../remote-host/technical-design.md#runtime)。
### ripgrep 打包 ### ripgrep 打包
固定依赖 `@vscode/ripgrep`,构建钩子根据 Electron 目标选择对应 Windows、macOS 或 Linux 固定依赖 `@vscode/ripgrep`,构建钩子根据 Electron 目标选择对应 Windows、macOS 或 Linux
+11 -5
View File
@@ -1,8 +1,12 @@
# 知识库 # 知识库
知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。 知识库已提供本地资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
将 Dify、FastGPT 和 RAGFlow 中已有知识库接入同一检索范围已完成 Main 进程适配器和 当前工作区已接入 Dify、FastGPT 和 RAGFlow 的实例管理、外部绑定、检索配置、统一检索、
真实接口验证,但实例管理、绑定、统一检索和界面尚未接通,因此仍不属于当前已提供能力。 聊天引用与界面。2026-09-13 已验证三家 Provider 的生产服务、本机 HTTP MCP 和生产 IPC
短答案生成,并从完整 App 输入区完成 Dify 检索、真实回答及引用点击。外部实例弹窗已修正
Portal 字段样式,并完成浅深主题和键盘检查。当前 UI 修正后的聚焦测试、全量回归及相关检查已通过;
FastGPT/RAGFlow 完整 App 与专属配置、真实远程预检索仍待验收。验证只覆盖有依据的短答案,
不代表广泛问答质量,详见[实施进度](./progress.md)。
## 文档导航 ## 文档导航
@@ -10,7 +14,7 @@
| --- | --- | | --- | --- |
| [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 | | [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 |
| [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 | | [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 |
| [UI 设计](./ui-design.md) | 当前本地知识库的信息架构、首次使用、状态与高级功能分层 | | [UI 设计](./ui-design.md) | 本地知识库的信息架构、首次使用、状态与外部界面分工 |
| [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 | | [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 |
| [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 | | [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 |
| [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 | | [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 |
@@ -18,10 +22,12 @@
| [外部知识库 User Stories](./external-knowledge-user-stories.md) | 实例配置、添加、使用和故障恢复场景 | | [外部知识库 User Stories](./external-knowledge-user-stories.md) | 实例配置、添加、使用和故障恢复场景 |
| [外部知识库逻辑设计](./external-knowledge-logic-design.md) | 状态、不变量、配置优先级和失败规则 | | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | 状态、不变量、配置优先级和失败规则 |
| [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 | | [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 |
| [外部知识库技术设计](./external-knowledge-technical-design.md) | Provider Adapter、凭据、持久化、IPC 和检索标准化 | | [外部知识库技术设计](./external-knowledge-technical-design.md) | 当前 Client、Service、Store、共享契约、IPC 和检索标准化 |
| [外部知识库实测基线](./external-knowledge-probe-baseline.json) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 | | [外部知识库实测基线](./external-knowledge-probe-baseline.json) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 |
| [2026-09-10 基础复测](./external-knowledge-probe-2026-09-10-strict.json) | 开启证书校验的目录、详情和基础检索结果 | | [2026-09-10 基础复测](./external-knowledge-probe-2026-09-10-strict.json) | 开启证书校验的目录、详情和基础检索结果 |
| [2026-09-10 扩展复测](./external-knowledge-probe-2026-09-10-extended.json) | 配置覆盖、重排、图谱与知识编译开关的请求结果及结构 | | [2026-09-10 扩展复测](./external-knowledge-probe-2026-09-10-extended.json) | 配置覆盖、重排、图谱与知识编译开关的请求结果及结构 |
| [2026-09-13 产品验证摘要](./external-knowledge-validation-2026-09-13.json) | 生产服务与本机 HTTP MCP、Dify 桌面、凭据重开、调用计数、最终自动检查状态及远程阻断的脱敏证据 |
| [2026-09-13 UI 与真实生成复验](./external-knowledge-follow-up-2026-09-13.json) | 后续 UI 修正、三家生产 IPC 生成、Dify 完整 App 回答与引用、视觉 fixture 及本轮请求计数 |
## 术语 ## 术语
@@ -34,4 +40,4 @@
或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。 或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。
本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约 本目录尚未拆出统一逻辑设计;当前本地知识库状态规则由 PRD、User Stories 和实现契约
共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。 定义。外部知识库的实例、绑定和失败规则由独立逻辑设计维护。
@@ -0,0 +1,106 @@
{
"date": "2026-09-13",
"kind": "sanitized-ui-and-real-generation-follow-up",
"sources": ["kb-real-generation/report.json", "kb-full-app-live/report.json", "external-visual-0913/results.json", "goodbuddy-binding-selector-fix-full.log", "goodbuddy-binding-selector-fix-full.result.json"],
"automatedChecks": {
"status": "passed-after-ui-follow-up",
"authoritativeCounts": "progress.md",
"focusedWholeAppAndTwoAgentTests": { "status": "passed", "evidence": "owner handoff" },
"fullSuite": {
"command": "npm test -- --maxWorkers=1 --reporter=verbose",
"status": "passed",
"exitCode": 0,
"evidence": ["goodbuddy-binding-selector-fix-full.log", "goodbuddy-binding-selector-fix-full.result.json"]
},
"typecheck": { "command": "npm run typecheck", "status": "passed", "evidence": "owner handoff" },
"lint": { "command": "npm run lint", "status": "passed", "evidence": "owner handoff" },
"workingTreeDiffCheck": { "status": "passed", "evidence": "owner handoff" },
"stagedDiffCheck": { "status": "passed", "evidence": "owner handoff" },
"sourceStability": {
"scope": "App test, two Agent tests and ExternalKnowledge stable throughout final run",
"evidence": "owner handoff"
},
"lastTestAdjustment": "App test changed from obsolete native select interaction to actual accessible rich menu interaction; no product source change",
"agentFailureResolution": "resolved in concurrent owner's latest code; no edits by this docs task",
"furtherSourceChanges": "none planned per owner handoff"
},
"model": {
"configuration": "saved default model settings; credential resolved in Main using safeStorage",
"transport": "http",
"originalSettingsUnchangedInFullAppRun": true,
"redaction": "No endpoints, private profile identifiers, keys, business text, answer prose or content hashes retained"
},
"productionIpcGeneration": {
"realElectronPreloadIpcAndModelRuntime": true,
"fullAppRenderer": false,
"referenceDisplay": "isolated validation DOM receiving real preload events",
"persistence": "production task_events; text, single-library scope and external locators verified",
"scenarios": [
{ "provider": "dify", "mode": "always", "passed": true },
{ "provider": "fastgpt", "mode": "always", "passed": false, "groundedQuote": true, "citationMarker": true, "failure": "initial raw-string evidence assertion did not account for serialization" },
{ "provider": "ragflow", "mode": "always", "passed": false, "groundedQuote": true, "citationMarker": true, "failure": "initial raw-string evidence assertion did not account for serialization" },
{ "provider": "dify", "mode": "auto", "passed": true, "transport": "ModelToolProvider to KnowledgeMcpGateway in process; not HTTP MCP", "tool": "knowledge_search" },
{ "provider": "fastgpt", "mode": "always", "passed": true, "evidenceCheck": "decoded provider body and embedded evidence JSON" },
{ "provider": "ragflow", "mode": "always", "passed": true, "evidenceCheck": "decoded provider body and embedded evidence JSON" }
],
"finalScenarioChecks": ["nonempty short answer", "grounded quote", "citation marker", "reference schema", "text and references persisted"],
"requests": { "model": 7, "kbTotal": 12, "kbRetrieval": 9, "kbCatalog": 3 },
"retrievalBreakdown": { "bindingVerification": 3, "forcedPreflight": 5, "autoScoped": 1 },
"runtimeModelRetries": 0,
"strictHttpsProviders": ["dify", "fastgpt"],
"httpProviders": ["ragflow"]
},
"fullApp": {
"provider": "dify",
"mode": "always",
"workMode": "ask",
"currentProductionMainPreloadRenderer": true,
"instanceAndPositiveBindingCreatedInUi": true,
"composerSelectionAndSendClicked": true,
"noFabricatedMessagesReferencesOrEvents": true,
"mainOnlyKbCredentialSubstitution": true,
"generationDone": true,
"answerPersisted": true,
"singleSelectedLibrary": true,
"liveEvidencePresentInModelInput": true,
"groundedShortAnswerWithCitation": true,
"citationClicked": true,
"citationSnippetDocumentAndChunkMatch": true,
"citationFocusInside": true,
"strictDifyHttps": true,
"modelHttp": true,
"requests": { "model": 1, "kbTotal": 5, "kbRetrieval": 3, "kbCatalog": 1, "kbDetail": 1 },
"setupVerificationRetrievals": 2,
"composerRetrievals": 1,
"toolExecutions": 0,
"portalEditor": { "themes": ["light", "dark"], "widthPx": 560, "sharedTypography": true, "withinViewport": true, "bodyOverflow": 0 },
"isolatedProfileAndBuildRemoved": true
},
"visualFixture": {
"boundary": "current source components and styles in real Electron with real body portal; fake local IPC data only",
"themes": ["light", "dark"],
"viewportSizes": [[1280, 800], [960, 720], [720, 640], [640, 420]],
"screenshots": 56,
"checksPassed": 138,
"errors": [],
"keyboardMethod": "native Chromium actions via Electron sendInputEvent and insertText",
"coverage": ["password reveal layout", "switch keyboard toggle", "sticky footer visibility", "focus wrap and restoration", "instance menu arrows and disabled items", "tabs", "credential replacement and clear confirmation", "selected hover", "empty state"]
},
"followUpRequestAccounting": {
"modelRequests": 8,
"kbRequests": 17,
"kbRetrievalRequests": 12,
"kbCatalogRequests": 4,
"kbDetailRequests": 1,
"boundary": "this follow-up only; app HTTP dispatches, not upstream proxy internals or provider embedding/reranking usage",
"cumulativeReportFieldsExcluded": "real-generation report cumulative fields omit prior desktop retrievals; not used"
},
"acceptanceBounds": {
"groundedShortAnswerPath": "passed for three providers through production IPC and Dify through full App composer",
"broadAnswerQuality": "not evaluated",
"fastgptRagflowFullAppAndProviderSpecificUi": "pending",
"linuxRemoteForcedPreflight": "still blocked by prior saved SSH credential decryption failure",
"fullRegressionAfterUiFollowUp": "passed",
"authoritativeAutomatedTestCounts": "progress.md"
}
}
@@ -4,345 +4,167 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 设计稿 | | 状态 | 当前实现规则;三家短答案、Dify 完整 App 与当前 UI 自动回归已通过,远程待验收 |
| 版本 | 0.1 | | 版本 | 0.2 |
| 日期 | 2026-09-03 | | 日期 | 2026-09-13 |
| 关联需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | | 关联需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 关联场景 | [外部知识库 User Stories](./external-knowledge-user-stories.md) | | 关联场景 | [外部知识库 User Stories](./external-knowledge-user-stories.md) |
| 技术契约 | [技术设计](./external-knowledge-technical-design.md) |
## 1. 不变量 ## 1. 不变量
### EK-I-01 单一知识库身份 | 稳定 ID | 规则 | 对应需求 |
| --- | --- | --- |
本地知识库和外部知识库都使用 GoodBuddy `knowledgeBaseId` 参与聊天范围、检索模式、 | `EK-I-01` 单一知识库身份 | 本地与外部都以 GoodBuddy `knowledgeBaseId` 参与聊天范围与工具授权,实例和远端 ID 不扩大选中范围 | `EK-FR-10`、`EK-FR-11` |
工具授权和活动记录。Provider、实例 ID 和远端知识库 ID 不能替代该授权单位。 | `EK-I-02` 外部绑定只读 | 添加、配置和移除仅修改本机记录;请求限于目录、详情、检索 | `EK-FR-15` |
| `EK-I-03` 凭据边界 | 新密钥在输入表单和保存 IPC 中短暂存在;加密后只在 Main 解密使用,摘要与引用不返回密钥 | `EK-FR-02`、`EK-FR-11` |
对应:`EK-FR-10`、`EK-FR-11`、`EK-US-C1`。 | `EK-I-04` 本地定义界面 | Provider 只返回数据,控件和配置由本地共享契约及组件定义 | `EK-FR-09` |
| `EK-I-05` 无静默切换 | 请求使用保存的实例、目标和配置;失败不更换实例、公共云地址或其他知识库 | `EK-FR-14`、`EK-FR-16` |
### EK-I-02 外部绑定只读 | `EK-I-06` 零结果与失败分离 | 合法成功响应无片段是零命中;请求或响应失败保留失败信息 | `EK-FR-14` |
| `EK-I-07` 外部证据不可信 | 标准化片段按已有知识证据边界进入 Runtime,不改变工具权限 | `EK-FR-12` |
GoodBuddy 对外部实例只执行连接测试、目录读取、详情读取和检索。添加、编辑、移除 | `EK-I-08` 配置不跨类型复用 | Provider 配置必须与实例一致,类型切换不迁移同名字段 | `EK-FR-06`、`EK-FR-09` |
均只修改本机记录,不调用远端创建、更新、上传或删除接口。
对应:`EK-FR-15`、`EK-US-A3`。
### EK-I-03 凭据不离开 Main
API Key 明文只在 Main 内完成解密并短暂进入请求头。Renderer、Preload 返回值、Runtime、
Provider 配置、知识库元数据、诊断、日志和错误只能获得凭据状态。
对应:`EK-FR-02`、`EK-FR-11`。
### EK-I-04 Provider 不能定义界面
远端响应只提供数据和能力证据。可显示字段、字段类型、范围、默认值和请求映射均来自
GoodBuddy 内置、版本化的 Provider Adapter。
对应:`EK-FR-09`、`EK-US-D1`。
### EK-I-05 无静默切换
请求固定使用绑定保存的实例、远端知识库和 Provider 配置。失败时不切换实例、公共云
地址、认证方式或其他知识库。
对应:`EK-FR-14`、`EK-FR-16`、`EK-US-C4`。
### EK-I-06 零结果与失败分离
成功响应经过过滤后没有片段才是零结果。超时、认证、权限、限流、目标不存在、服务错误
和响应无效均为失败,不能折叠成零结果。
对应:`EK-FR-14`、`EK-US-D4`。
### EK-I-07 外部证据不可信
Provider 返回的名称、内容、元数据、URL 和错误均视为不可信输入,经过长度、数量、类型
和 URL 校验后才能显示或进入 Runtime。内容中的指令不能改变系统或工具权限。
对应:`EK-FR-12`。
### EK-I-08 Provider 配置不跨类型复用
Dify、FastGPT 和 RAGFlow 使用各自的判别配置。切换知识库类型或实例 Provider 时,旧类型
配置不参与新请求,也不能按同名字段自动迁移。
对应:`EK-FR-06`、`EK-FR-09`。
## 2. 对象关系 ## 2. 对象关系
```text 一个实例可以有零个或多个绑定;一个绑定对应一个 GoodBuddy 知识库和一个远端目标。
ExternalKnowledgeInstance 1 ─── n ExternalKnowledgeBinding 同一实例与远端 ID 的组合唯一,不同实例可以使用相同 ID。本地知识库没有外部绑定。
│ 类型由绑定关系推导,旧本地知识库不需要改成另一套对象。
│ 1:1
▼
KnowledgeBase
```
- 实例可以没有绑定。 GoodBuddy 显示名称与 `remoteName` 分开保存。刷新详情仅更新当前视图,不自动改写本地
- 外部知识库只能绑定一个实例和一个远端知识库 ID。 名称或持久化远端名称;重新保存绑定时才写入当前表单使用的远端名称。
- 同一实例的同一远端知识库默认只允许存在一个绑定,避免重复选择和重复检索。
- 允许不同实例绑定同名或相同远端 ID,因为实例边界不同。
- 本地知识库不存在 `ExternalKnowledgeBinding`。
## 3. 实例状态 ## 3. 实例状态与连接测试
实例状态由启停、凭据和最近探测三个维度组成,不能用一个布尔值代替。 | 状态维度 | 当前值与行为 |
### 3.1 持久状态
| 维度 | 值 |
| --- | --- | | --- | --- |
| 启停 | `enabled`、`disabled` | | 启停 | `enabled` 为 false 时 Main 拒绝目录和检索,并取消当前实例请求 |
| 凭据 | `configured`、`missing`、`unavailable` | | 凭据 | `configured`、`missing`、`unavailable`;列表尝试解密判断可用性 |
| 探测 | `untested`、`catalog-ready`、`healthy`、`list-restricted`、`auth-failed`、`unreachable`、`incompatible`、`failed` | | 最近探测 | `untested`、`catalog-ready`、`healthy`、`list-restricted`、`auth-failed`、`unreachable`、`failed`;其中 `healthy` 在类型中保留,当前操作没有写入它 |
`unavailable` 表示系统安全存储当前无法解密或保存凭据,与用户尚未配置的 `missing` 分开。 实例先保存为 `untested`,不要求保存前完成网络或认证测试。保存、替换或清除凭据后
重置探测状态。保存后的“测试连接”只尝试目录读取:成功写入 `catalog-ready`;403
写入 `list-restricted`;401 写入 `auth-failed`;网络或超时写入 `unreachable`;其他错误
写入 `failed`。时间和错误码随本次结果保存。
### 3.2 派生可用性 探测结果是最近一次观察,不是 Main 的永久禁用条件。Main 请求检查实例是否存在、启用
和凭据能否解密;即使最近为 `auth-failed` 或 `failed`,仍可重新请求。UI 会据该状态
显示不可用并禁用聊天勾选,用户可编辑或重新测试实例。
| 条件 | 目录读取 | 已绑定知识库检索 | 页面状态 | 绑定创建另做目标检索验证,成功零命中可保存。保存实例不代表目录可用,目录成功也不
| --- | --- | --- | --- | 代表绑定检索或整个产品验收通过。
| disabled | 禁止 | 禁止 | 已停用 |
| missing / unavailable | 禁止 | 禁止 | 缺少凭据 / 凭据不可用 |
| auth-failed | 禁止 | 禁止 | 认证失效 |
| unreachable | 可重试 | 可重试 | 无法连接 |
| incompatible | 禁止 | 禁止 | 接口不兼容 |
| list-restricted | 禁止 | 允许按 ID 验证与检索 | 需手工指定知识库 |
| catalog-ready | 允许 | 创建绑定时验证 | 目录可用,检索待验证 |
| healthy | 允许 | 允许 | 正常 |
| untested | 允许测试 | 已有绑定可尝试检索 | 未测试 |
保存新实例要求至少完成服务可达和认证验证。目录受限可以保存;检索接口尚未通过时, ## 4. 目录与手工 ID
实例不能用于创建绑定。
## 4. 连接测试状态机 目录每页最多 100 项,UI 当前请求 30 项,页码上限 500;当前没有“总目录最多 500 项”
的累计限制。Dify/RAGFlow 由远端分页,搜索过滤当前页;FastGPT 按 `parentId` 读取
当前父级,在返回结果内搜索和分页。文件夹只能进入浏览,不能作为目录绑定目标。
```text 目录加载、空结果、搜索无结果和失败分别展示;失败提供重试,用户可主动切换手工 ID。
idle 已经绑定的目录项禁用。手工 ID 不依赖详情接口一定可用,但保存前仍必须通过实际检索。
└─ test → connecting 详情失败不阻断基础检索,例如旧 Dify 的详情 405。
├─ network failure → unreachable
└─ connected → authenticating
├─ 401 → auth-failed
├─ incompatible response → incompatible
└─ authenticated → listing
├─ 403 / unsupported → list-restricted
└─ success
├─ no target → catalog-ready
└─ target selected → probing-retrieval
├─ success / empty → healthy
└─ failure → failed
```
检索探测需要一个目标知识库。新增实例尚未选择目标时,目录成功即可把实例保存为“目录可用, Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的结果写回当前表单。它没有
检索待绑定验证”;创建绑定时补做真实检索。手工模式必须先填写目标 ID,再执行检索探测。 通过 IPC 取消已发出的目录请求,Main 请求仍受超时和服务生命周期管理。
检索请求正常完成但返回零条结果时,说明接口和目标可访问,可继续创建绑定;失败状态按错误
分类处理。
每次测试拥有 request ID 和取消信号。用户修改 Provider、地址、凭据或实例后,旧 request ID ## 5. 创建与编辑
的结果作废。
## 5. 目录读取规则 流程为选择 `local | dify | fastgpt | ragflow`,外部类型再选择实例、目录项或手工 ID、
显示名称、配置与测试查询。表单测试成功后允许保存;服务保存前再次执行同一查询验证,
检查实例、绑定和唯一性是否在等待期间改变,再写入数据库。
1. 只有选定、启用且凭据可用的实例可以读取目录。 | 表单变化 | 当前失效规则 |
2. Provider 列表接口按适配器定义分页,Main 最多向 Renderer 返回 500 个目录项。
3. FastGPT 列表中的文件夹和知识库必须按类型区分;文件夹不可作为绑定目标。
4. 列表搜索优先使用 Provider 支持的服务端过滤,不支持时只过滤已经有界加载的当前结果。
5. 下拉列表以远端 ID 为值,以远端名称为标签;重名时追加短 ID 或父级名称。
6. 已经绑定的目标显示“已添加”并禁止重复添加。
7. 远端列表项名称变化不自动修改 GoodBuddy 显示名称,只更新远端名称快照。
8. 刷新失败时可以保留上次成功目录供查看,但不得用旧目录完成新绑定,直到目标重新验证。
## 6. 创建向导状态
### 6.1 类型
```text
local | dify | fastgpt | ragflow
```
`local` 使用现有字段和验证。外部类型使用以下步骤状态:
```text
provider selected
→ instance selected
→ catalog loading / manual ID
→ remote target selected
→ configuration valid
→ retrieval verified
→ binding created
```
前置字段变化时,后续状态按下表失效:
| 变化 | 必须清除或重新验证 |
| --- | --- | | --- | --- |
| 类型 | 实例、远端目标、全部 Provider 配置、测试结果 | | Provider 类型 | 重新挂载绑定表单,清空旧类型状态 |
| 实例 | 远端目标、能力快照、Provider 配置、测试结果 | | 实例 | 清空目标、详情、目录位置和搜索,恢复该 Provider 默认配置,清除测试结果 |
| 远端目标 | 远端详情、能力适用性、测试结果 | | 远端目标、测试查询、通用或 Provider 参数 | 清除测试结果,需要重新测试 |
| 通用或 Provider 配置 | 测试结果 | | 显示名称 | 保留测试结果 |
| 显示名称 | 不清除测试结果 | | 已保存实例摘要变化 | 旧测试结果不再视为有效 |
| 更换现有绑定实例或远端目标 | 需要重新测试和重新绑定确认 |
## 7. 配置模型 成功新建后仅把新库加入当前对话选择,不重新启用已取消的其他知识库。当前表单保存期间
禁用字段和操作;generation 检查用于避免旧响应更新 UI,不构成可取消的保存事务。
### 7.1 通用配置 ## 6. 配置与优先级
| 字段 | 范围 | 默认值 | 语义 | 当前配置的精确字段、数值范围和默认值由[技术设计第 3 节](./external-knowledge-technical-design.md#3-共享契约)
| --- | --- | --- | --- | 对应的共享 Schema 定义。恢复默认只重置本地表单,保存成功后才影响该绑定后续请求。
| `resultLimit` | 1 至 20 | 6 | Provider 结果标准化后最多保留的片段数 |
| `requestTimeoutMs` | 1,000 至 60,000 | 15,000 | 单次远端请求总时限 |
| `maxSnippetCharacters` | 500 至 8,000 | 4,000 | 单片段进入 IPC 和 Runtime 前的字符上限 |
Provider 请求仍使用自己的候选数量或 Token 预算。`resultLimit` 只定义 GoodBuddy 最终保留 Dify `useDatasetDefaults=true` 时省略整个覆盖对象,使用远端当时配置;远端管理员修改
上限,不改写 Provider 字段的单位。 默认值会影响后续结果。关闭后按完整覆盖对象发送,不与远端默认逐字段合并。
FastGPT 使用本地保存的模式、Token 预算、相似度和重排开关,不开放查询优化。
### 7.2 Dify 配置 RAGFlow 图谱与知识编译分别保存且默认关闭。图谱完成时间有效才允许开启图谱;非空
知识编译模板组 ID 才允许开启知识编译。服务在发送启用请求前重新读详情确认;仅配置
GraphRAG 或请求返回 200 都不能替代对应能力证据。证据不足时 UI 仍允许关闭已保存的开关。
| 字段 | 初始范围 | 说明 | 当前没有自动版本识别、Adapter 版本矩阵或配置迁移状态。共享契约拒绝未知输入与
Provider 错配,不能据此承诺所有部署版本均支持每个显式参数。
## 7. 绑定可用性
绑定只持久化配置和 `lastVerifiedAt`,没有独立 `binding_status`、最后成功检索时间或
远端缺失状态字段。详情与列表当前按实例推导:
| 条件 | UI 状态 |
| --- | --- |
| 实例不存在 | `temporarily-unavailable` |
| 实例停用 | `instance-disabled` |
| 凭据未配置、无法解密或最近认证失败 | `credential-error` |
| 最近探测失败或不可达 | `temporarily-unavailable` |
| 其余且绑定有验证时间 | `ready` |
| 其余且无验证时间 | `untested` |
聊天选择器保留已选 ID,但不可用行禁用勾选。检索时的 401、404、超时等通过本次失败
报告,不自动写成持久 `remote-missing/config-invalid` 状态。`ready` 表示当前可尝试使用,
不保证下一次请求成功。
## 8. 多库检索与失败
请求使用已冻结的 GoodBuddy ID 集合,`retrieveMany` 去重后并行检索各库。本地与外部
共享入口,外部库不触发本地 Embedding。预算按各库结果的 rank 轮转分配,先各库第 1 条、
再各库第 2 条,同 rank 按输入库顺序处理;不先耗尽整个库,也不比较 Provider 原始分数。
当前没有每库最低字符配额。精确预算见
[技术设计第 7 节](./external-knowledge-technical-design.md#7-检索编排)。
成功零命中返回空 results 且无 failure。单库错误返回 `diagnostics.failure`,其他库
仍可返回结果;上层据此区分部分失败与全部失败。MCP 在有失败且没有任何片段时返回
工具错误,即使另有成功零命中的库;预检索则保留降级诊断。用户取消或服务关闭会向上
传播取消,不把整次取消解释为成功零命中。
客户端错误码包括 `NETWORK/TIMEOUT/CANCELLED/AUTH/FORBIDDEN/NOT_FOUND/RATE_LIMITED/`
`INCOMPATIBLE/INVALID_RESPONSE/SERVER`,均以 `EXTERNAL_KB_` 为前缀。服务另报告凭据
不可用、停用、配置变化、配置错配、能力不可用、重复绑定和实例被引用。远端错误正文
不直接成为 UI 错误;部分本地校验错误仍是 Zod、TypeError 或 RangeError,不能承诺
所有失败都已有独立本地化诊断。
## 9. 删除与引用
停用和清除凭据保留绑定,UI 清除凭据需要确认。有绑定的实例不能直接删除;管理界面
列出受影响知识库并确认后,依次移除本地库再删实例。若中途失败,已成功移除的本地
记录保持删除,刷新列表后显示当前状态。任何一步均不发送远端内容删除请求。
历史引用保存当时的片段与外部定位,移除实例或绑定后仍能阅读。当前不重新读取远端
片段,不打开来源 URL,不查询实例是否已删除;因此没有自动“连接已移除”标签。
App 与 Main 引用处理共用 `knowledgeReferenceKey`,键包含远端库、文档和片段 ID、
定位及正文,避免外部结果缺少本地 ID 时被错误合并。弹窗只显示优先选中的一个定位。
早期 Dify 历史引用保存和重载检查使用测试会话;后续完整 App 已从实际输入区生成真实
短答案并点击引用验证。两轮证据分别保留,不把测试会话当成模型回答。
## 10. 需求追踪与完整性
| 需求 | 规则 | 场景 |
| --- | --- | --- | | --- | --- | --- |
| `useDatasetDefaults` | boolean,默认 true | 开启时不发送 `retrieval_model` 覆盖 | | `EK-FR-01` 至 `EK-FR-05` | 实例状态、保存后测试、目录与手工 ID | `EK-US-A1`、`EK-US-A2`、`EK-US-B1`、`EK-US-B2` |
| `searchMethod` | keyword / semantic / full-text / hybrid | 对应 Dataset Retrieve 检索方式 | | `EK-FR-06` 至 `EK-FR-10` | 对象关系、创建编辑、配置与优先级 | `EK-US-B3`、`EK-US-B4`、`EK-US-D1`、`EK-US-D3` |
| `providerTopK` | 1 至适配器上限 | Dify 返回候选数量 | | `EK-FR-11` 至 `EK-FR-14` | 范围、可用性、结果与失败、引用 | `EK-US-C1` 至 `EK-US-C4`、`EK-US-D2`、`EK-US-D4` |
| `scoreThresholdEnabled` | boolean | 是否向 Dify 发送分数阈值 | | `EK-FR-15`、`EK-FR-16` | 只读生命周期、凭据与请求边界 | `EK-US-A3`、`EK-US-C2` |
| `scoreThreshold` | 0 至 1 | 开启阈值后生效 |
| `rerankingMode` | weighted-score / reranking-model | 覆盖检索配置时必填 |
| `rerankingProvider`、`rerankingModel` | 受支持的远端标识 | 选择模型重排时必填 |
| `keywordWeight`、`vectorWeight` | 0 至 1,总和为 1 | 加权混合模式时生效 |
| `embeddingProvider`、`embeddingModel` | 受支持的远端标识 | Dify 要求时从知识库详情选择或填写 |
`useDatasetDefaults` 开启时,其余覆盖字段隐藏且不发送。关闭后必须收集当前 Dify 接口 三家真实 Provider 的生产服务、本机 HTTP MCP、生产 IPC `always` 短答案,以及 Dify
构造 `retrieval_model` 所需的完整字段,缺少 Provider 或模型标识时不能保存。 `auto` 与完整 App 输入区回答、引用已有证据,详见[实施进度](./progress.md)。`auto` 本轮
使用进程内工具调用,不覆盖远程 ACP。FastGPT/RAGFlow 完整桌面、复杂问答和远程
### 7.3 FastGPT 配置 forced-preflight 仍待完成;远程已确认网络可达,但已有 SSH 凭据在验证程序内无法解密。
后续 Portal 与控件修正后的聚焦测试、全量回归及相关检查已通过,精确结果由实施进度维护。
| 字段 | 初始范围 | 说明 | 原场景中的保存前分项测试、按地址
| --- | --- | --- | 传输确认、取消交互、版本能力状态和完整诊断尚不等同当前实现,不能把对应 User Story
| `searchMode` | embedding / fullTextRecall / mixedRecall | 搜索方式 | 全部标为通过。是否调整这些验收要求由功能 owner 确认,本轮不增加新的状态框架。
| `tokenLimit` | 适配器限制,最高 20,000 | Provider 返回内容预算,不等同结果条数 |
| `similarity` | 0 至 1 | 最低相关度 |
| `usingRerank` | boolean | 使用 FastGPT 重排 |
初始版本不开放 FastGPT 查询优化字段,避免把查询发送给 FastGPT 配置的 LLM。检索和重排
仍可能产生远端 Embedding、Rerank 用量及 API 审计记录,测试与保存配置时必须说明。
### 7.4 RAGFlow 配置
| 字段 | 初始范围 | 说明 |
| --- | --- | --- |
| `similarityThreshold` | 0 至 1 | 最低综合相似度 |
| `vectorSimilarityWeight` | 0 至 1 | 向量分数权重 |
| `knnTopK` | 1 至适配器上限 | KNN 候选数量,不使用已弃用 `top_k` |
| `rerankCandidatesCount` | 0 或正整数 | 重排候选数量,实例支持时显示 |
| `rerankId` | 可选受限字符串 | 远端重排模型 ID |
| `useKg` | boolean,默认 false | 仅在已有图谱证据成立时允许开启 |
| `includeKnowledgeCompilation` | boolean,默认 false | 实例支持时显式发送,禁止依赖服务端默认 true |
| `metadataCondition` | 后续受控编辑器 | 初始版本不接受任意 JSON 文本 |
`parser_config.graphrag.use_graphrag` 描述构建状态,不是检索参数。只有列表、详情或受控
探测提供已有图谱的证据时,界面才允许开启 `useKg`。Knowledge Compilation 是另一项
检索能力,必须与旧 GraphRAG 分别显示和保存。
## 8. 配置优先级
生效请求由以下信息组成:
```text
GoodBuddy 固定安全上限
∩ 当前 Adapter 支持矩阵
∩ 当前绑定保存的通用配置和 Provider 配置
= 本次请求配置
```
- 固定安全上限不可被实例或用户扩大。
- 显式保存 Provider 参数时,远端默认值只用于创建表单的建议值。Dify
`useDatasetDefaults=true` 是例外:每次请求省略 `retrieval_model`,沿用远端当时的配置;
远端管理员修改配置后,后续检索随之变化。本地不保存一份默认参数快照。
- Provider 增加新字段不会自动启用。
- 当前实例版本离开 Adapter 支持矩阵,或 Adapter 新版本无法解析已保存配置时,绑定标记
配置失效;用户确认前不重写配置。
- 服务端忽略未知字段不能视为成功。Adapter 只发送已知且受支持的字段。
## 9. 绑定状态
| 状态 | 条件 | 可进入聊天范围 |
| --- | --- | --- |
| `ready` | 实例可用,目标最近验证成功,配置有效 | 是 |
| `untested` | 迁移或新增后尚未完成真实检索 | 否 |
| `instance-disabled` | 实例停用 | 否 |
| `credential-error` | 凭据缺失、不可用或认证失败 | 否 |
| `remote-missing` | 远端目标返回不存在 | 否 |
| `config-invalid` | 已保存配置不再受支持 | 否 |
| `temporarily-unavailable` | 超时、限流或服务错误 | 是,但本次显示失败并允许重试 |
短暂故障不立即永久禁用绑定;认证失败、目标不存在和配置失效会改变持久状态。
## 10. 多知识库检索
1. 读取请求冻结的 GoodBuddy 知识库 ID 集合。
2. 本地库进入本地检索,外部库按实例和绑定构造独立请求。
3. 同一请求最多选择现有契约允许的 20 个知识库,并遵循全局并发上限。
4. 每个库单独记录成功、零结果、失败、耗时和截断。
5. 成功结果按知识库保留来源,不比较不同 Provider 的原始分数。
6. 上下文拼装使用现有总字符预算,在各成功知识库间采用确定性配额。
7. 全部失败时返回检索失败;至少一个成功时返回证据和部分失败诊断。
初始版本不对跨 Provider 结果运行统一学习型重排。后续如增加,必须保留原始 Provider
分数和重排来源,且单独定义评估数据。
## 11. 错误分类
| 稳定错误码 | 用户状态 | 持久影响 |
| --- | --- | --- |
| `EXTERNAL_KB_NETWORK` | 无法连接实例 | 无 |
| `EXTERNAL_KB_TIMEOUT` | 检索超时 | 无 |
| `EXTERNAL_KB_AUTH` | 认证失效 | 实例标记认证失败 |
| `EXTERNAL_KB_FORBIDDEN` | 没有访问权限 | 目录或绑定按操作标记受限 |
| `EXTERNAL_KB_NOT_FOUND` | 远端知识库不存在 | 绑定标记目标不存在 |
| `EXTERNAL_KB_RATE_LIMITED` | 请求过于频繁 | 无,可显示建议等待 |
| `EXTERNAL_KB_INCOMPATIBLE` | 接口或配置不兼容 | 实例或绑定标记不兼容 |
| `EXTERNAL_KB_INVALID_RESPONSE` | 返回内容无法读取 | 无,记录脱敏诊断 |
| `EXTERNAL_KB_SERVER` | 外部服务处理失败 | 无 |
| `EXTERNAL_KB_CANCELLED` | 已取消 | 无 |
Provider 原始正文不能直接成为用户错误。Adapter 将其映射为稳定错误码和有界短消息。
## 12. 删除规则
| 操作 | 无绑定 | 有绑定 |
| --- | --- | --- |
| 停用实例 | 直接停用 | 允许;绑定变为不可用 |
| 清除凭据 | 直接清除 | 需要确认;绑定保留但不可用 |
| 删除实例 | 直接删除 | 阻止直接删除,要求同时移除本地绑定 |
| 移除绑定 | 删除本地绑定 | 不影响实例和远端知识库 |
删除确认不得使用“删除远端知识库”等文案。
## 13. 需求追踪
| 需求 | 主要逻辑 | User Story |
| --- | --- | --- |
| `EK-FR-01` 至 `EK-FR-03` | 实例状态、连接测试状态机 | `EK-US-A1`、`EK-US-A2` |
| `EK-FR-04`、`EK-FR-05` | 目录读取规则、手工目标验证 | `EK-US-B1`、`EK-US-B2` |
| `EK-FR-06`、`EK-FR-07`、`EK-FR-08`、`EK-FR-09` | 创建向导状态、配置模型和优先级 | `EK-US-B3`、`EK-US-B4`、`EK-US-D1`、`EK-US-D3` |
| `EK-FR-10` 至 `EK-FR-13` | 单一身份、多知识库检索和引用规则 | `EK-US-C1` 至 `EK-US-C3`、`EK-US-D2` |
| `EK-FR-14` | 绑定状态、错误分类和部分失败 | `EK-US-C4`、`EK-US-D4` |
| `EK-FR-15` | 对象关系和删除规则 | `EK-US-A3` |
| `EK-FR-16` | 无静默切换、请求冻结和传输风险确认 | `EK-US-C2` |
## 14. 逻辑完整性
已确定:
- 产品入口、对象关系、只读边界和凭据边界。
- 目录读取与手工 ID 的切换条件。
- 三种 Provider 的初始配置范围和 RAGFlow 图谱语义。
- 多知识库部分失败、删除和版本变化规则。
实施前仍需技术验证:
- 已完成第一轮真实目录与最小检索验证,并确认 Dify `1.17.0` 非空结果结构;仍需确认
FastGPT、RAGFlow 产品版本和 Dify `0.15.8` 非空结果,再选定三者最低版本并形成固定
支持矩阵与去敏 fixture。当前实测详情见
[技术设计 9.0 节](./external-knowledge-technical-design.md#90-真实实例验证基线)。
- 验证各版本检索探测的 API 审计、用量和内容修改行为。
- 确定 Provider 返回 URL 的安全打开策略;未验证前只显示文本定位和远端 ID。
- 依据真实延迟确定默认并发上限和跨知识库上下文配额。
@@ -4,14 +4,38 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 设计稿 | | 状态 | 三家短答案、Dify 完整 App 与当前 UI 自动回归已通过;剩余产品验收待完成 |
| 版本 | 0.1 | | 版本 | 0.2 |
| 日期 | 2026-09-03 | | 日期 | 2026-09-13 |
| 适用产品 | GoodBuddy 桌面端 | | 适用产品 | GoodBuddy 桌面端 |
| 关联逻辑 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | | 关联逻辑 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| 关联界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) | | 关联界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
| 技术设计 | [外部知识库技术设计](./external-knowledge-technical-design.md) | | 技术设计 | [外部知识库技术设计](./external-knowledge-technical-design.md) |
## 当前交付范围
当前工作区已提供实例管理、加密凭据保存、目录与手工 ID、外部绑定、Provider 检索参数、
统一知识库列表、聊天预检索与按需工具检索、外部引用和本地移除的实现。参数与恢复默认
遵循下文只读产品决策。2026-09-13 已验证三家 Provider 的生产服务与本机 HTTP MCP,
以及 Dify 桌面的实例保存、绑定、参数更新、重启和历史引用。后续已修正外部弹窗的字段
样式与布局,并验证三家通过生产 IPC 生成有依据的短答案、Dify 按需检索,以及从完整 App
输入区发送 Dify 问题并点击真实答案引用。该证据不代表广泛问答质量。
FastGPT/RAGFlow 完整桌面、远程预检索仍待验收;当前 UI 修改后的聚焦测试、全量回归与
相关检查已通过,精确结果见[实施进度](./progress.md)。
本文的功能需求和产品验收仍是验收目标。以下差异不能仅凭实现存在就标为满足:
| 原需求 | 当前实现与待核对差异 |
| --- | --- |
| `EK-FR-02`、`EK-FR-03` | 实例先保存为未测试,再在列表执行目录测试;目标检索在绑定时验证。没有分项草稿探测、版本或完整能力快照 |
| `EK-FR-04` | 已有目录搜索和分页;Dify/RAGFlow 搜索过滤当前页,FastGPT 搜索当前父级响应,不能保证全目录搜索 |
| `EK-FR-09` | 内置严格配置已实现,没有按探测版本维护支持矩阵;Dify 高级只读能力展示、Metadata 编辑和 RAGFlow Rerank 未提供 |
| `EK-FR-13`、`EK-FR-14` | 已显示片段、原始评分、总耗时和失败;没有完整的 Provider 耗时、裁剪前数量与截断诊断,也不持久化远端目标失效状态 |
| `EK-FR-16` | Main 固定端点及数据说明已接入;当前没有单独接入全局内网兼容模式和按地址传输确认流程 |
这些差异由功能 owner 在验收时核对。2026-09-09、2026-09-10 真实接口探测只作为历史
兼容性资料,不作为本轮桌面产品验证结果。
## 1. 背景 ## 1. 背景
不少企业已经在内网部署 Dify、FastGPT 或 RAGFlow,并在其中维护制度、产品、研发 不少企业已经在内网部署 Dify、FastGPT 或 RAGFlow,并在其中维护制度、产品、研发
@@ -42,6 +66,11 @@ GoodBuddy 负责。
10. API Key 在用户输入表单和受限 IPC 中短暂存在,由 Main 进程加密保存并用于请求; 10. API Key 在用户输入表单和受限 IPC 中短暂存在,由 Main 进程加密保存并用于请求;
保存后不回传 Renderer,也不进入 Runtime、日志、普通元数据和检索引用。 保存后不回传 Renderer,也不进入 Runtime、日志、普通元数据和检索引用。
第一版按只读接入交付:参数修改和恢复默认只更新 GoodBuddy 中当前绑定的客户端配置,
在后续检索请求中使用,不修改远端知识库默认配置,也不影响其他客户端。远端文档、
索引、图谱构建、模型和 Pipeline 设置由用户在对应平台维护。检索请求仍可能产生远端
Embedding、重排用量和审计记录。
## 3. 目标 ## 3. 目标
### 3.1 用户目标 ### 3.1 用户目标
@@ -93,22 +122,23 @@ GoodBuddy 负责。
1. 用户在知识库页面点击“管理外部实例”。 1. 用户在知识库页面点击“管理外部实例”。
2. 页面列出实例名称、Provider、服务地址、状态和已绑定知识库数量。 2. 页面列出实例名称、Provider、服务地址、状态和已绑定知识库数量。
3. 用户添加实例,填写名称、Provider、服务地址和 API Key。 3. 用户添加实例,填写名称、Provider、服务地址和 API Key。
4. “测试连接”分别验证服务可达、认证、知识库列表和检索能力。 4. 用户先保存实例,API Key 被加密保存,列表显示凭据状态和“未测试”。
5. 用户保存后,API Key 被加密保存,列表只显示凭据已配置。 5. 在已保存实例上“测试连接”读取目录并记录结果;检索能力在绑定目标时验证。
列表接口不可用但已知远端知识库 ID 可检索时,实例可以保存为“需手工指定知识库”。 目录 403 显示“需手工指定知识库”;不可达或认证失败保留实例和失败状态,用户可编辑
服务不可达或认证失败时不得保存为可用实例;用户可以保留表单并修正。 后重试。保存本身不代表可用。原分项测试验收目标见 `EK-FR-03`,当前实现差异见上表。
### 6.2 添加外部知识库 ### 6.2 添加外部知识库
1. 用户点击现有“新建知识库”。 1. 用户点击现有“新建知识库”。
2. 在“知识库类型”中选择“本地”、Dify、FastGPT 或 RAGFlow。 2. 在“知识库类型”中选择“本地”、Dify、FastGPT 或 RAGFlow。
3. 选择外部类型后,左侧选择同 Provider 的外部实例。 3. 选择外部类型后,左侧选择同 Provider 的外部实例。
4. GoodBuddy 自动读取远端知识库列表,并填入可搜索下拉框。 4. GoodBuddy 自动读取远端知识库列表,提供搜索、分页和目标选择。
5. 列表权限不可用时,用户可以手工填写远端知识库 ID。 5. 列表权限不可用时,用户可以手工填写远端知识库 ID。
6. 用户选择一个远端知识库,填写 GoodBuddy 显示名称。 6. 用户选择一个远端知识库,填写 GoodBuddy 显示名称。
7. 右侧展示通用配置和当前 Provider 的专属配置。 7. 右侧展示通用配置和当前 Provider 的专属配置。
8. 用户填写测试查询并执行一次有界检索;请求成功后创建本地绑定,零结果不算失败。 8. 用户填写测试查询并执行有界检索;成功后可添加,零结果不算失败。保存时服务会再次
执行该查询验证目标和配置,之后创建本地绑定。
添加完成后沿用现有知识库规则,把新绑定加入当前聊天选择,但不会重新启用用户此前 添加完成后沿用现有知识库规则,把新绑定加入当前聊天选择,但不会重新启用用户此前
取消的其他知识库。 取消的其他知识库。
@@ -222,13 +252,20 @@ Provider 的远端创建、更新、上传或删除接口。
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Dify | 知识库列表与详情 | Dataset Retrieve | 使用知识库默认配置,或完整覆盖检索方式、Top K、Score 阈值及 Rerank 模式 | | Dify | 知识库列表与详情 | Dataset Retrieve | 使用知识库默认配置,或完整覆盖检索方式、Top K、Score 阈值及 Rerank 模式 |
| FastGPT | 知识库层级列表与详情 | Dataset Search Test | 搜索模式、Token 上限、相似度、Rerank | | FastGPT | 知识库层级列表与详情 | Dataset Search Test | 搜索模式、Token 上限、相似度、Rerank |
| RAGFlow | Dataset 列表与详情 | Retrieval | 相似度、向量权重、KNN Top K、Rerank、图谱检索、Knowledge Compilation | | RAGFlow | Dataset 列表与详情 | Retrieval | 相似度、向量权重、KNN Top K、图谱检索、Knowledge Compilation;当前未提供 Rerank 配置 |
“首批专属配置”表示适配器允许表达的字段,不保证每个实例版本都支持。具体显隐由能力 “首批专属配置”表示适配器允许表达的字段,不保证每个实例版本都支持。具体显隐由能力
探测和[逻辑设计](./external-knowledge-logic-design.md)共同决定。 探测和[逻辑设计](./external-knowledge-logic-design.md)共同决定。
## 9. 产品验收 ## 9. 产品验收
以下为验收清单,尚未整体通过。第 2 项已有三家生产服务绑定与检索证据,Dify 另有桌面
操作证据;其他 Provider 的桌面、受限目录与高级配置等仍需补验。第 12 项确认流程当前
未实现。早期历史引用检查使用标记为测试的会话;后续已从完整 App 输入区生成 Dify
真实短答案并点击引用,三家另有生产 IPC 短答案证据。完整 App 尚未覆盖 FastGPT/RAGFlow,
也未评估广泛问答质量;本机 MCP 成功不替代被 SSH 凭据解密阻断的真实远程预检索。
详细结果以实施进度为准。
1. 用户可以在知识库页面完成三种 Provider 的实例添加、连接测试、编辑和删除。 1. 用户可以在知识库页面完成三种 Provider 的实例添加、连接测试、编辑和删除。
2. 每种 Provider 至少通过一个真实自部署实例完成列表读取、绑定和检索。 2. 每种 Provider 至少通过一个真实自部署实例完成列表读取、绑定和检索。
3. 列表权限关闭时,可以通过手工 ID 添加并验证一个可检索知识库。 3. 列表权限关闭时,可以通过手工 ID 添加并验证一个可检索知识库。
@@ -4,721 +4,309 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 设计稿 | | 状态 | 三家生产 IPC 真实生成、Dify 完整 App 与当前 UI 自动回归已通过;远程待验收 |
| 版本 | 0.1 | | 版本 | 0.2 |
| 日期 | 2026-09-03 | | 日期 | 2026-09-13 |
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | | 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 逻辑设计 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | | 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| UI 设计 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) | | 界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
| 验证状态 | [实施进度](./progress.md) |
## 1. 设计约束 ## 1. 实现边界
1. 外部知识库扩展现有知识库领域,不建立独立检索工具、聊天范围或 Runtime 权限模型。 外部知识库复用 GoodBuddy 知识库 ID、聊天范围和检索入口。网络、凭据、响应读取和
2. 外部绑定使用远端检索,不落地远端文档、分块、向量和图谱。 标准化在 Main 完成,Renderer 通过明确的 Preload 方法调用 IPC。远端只执行目录、详情和
3. 所有网络、凭据、Provider 响应校验和结果标准化都在 Main 完成。 检索请求,不创建、修改、上传或删除远端内容,不调用外部 App、Chat、Workflow 或 Agent。
4. Renderer 只能通过受限 IPC 管理实例、读取目录、保存绑定和发起测试。
5. Dify、FastGPT、RAGFlow 使用独立 Adapter 和判别配置,不通过任意 JSON 请求模板实现。
6. 首次实现只调用无内容修改语义的列表、详情和检索接口。
7. Provider API 变化通过真实 fixture、Adapter 版本和能力快照处理,不通过猜测字段兼容。
## 2. 现有代码边界 本地 `file | directory | url` 仍表示需要导入和索引的资料来源。外部绑定独立存储,
不加入该枚举,也不生成本地文档、分块、向量或图谱。实际用于回答的片段随会话引用保存。
当前实现中的主要扩展点: ## 2. 当前模块
| 边界 | 现有位置 | 需要的变化 | | 文件 | 职责 |
| --- | --- | --- | | --- | --- |
| 共享知识契约 | `src/shared/knowledge-contracts.ts`、`src/shared/contracts.ts` | 增加实例、绑定、Provider 配置和外部引用判别联合 | | `src/main/knowledge/external/external-knowledge-client.ts` | 一个 `ExternalKnowledgeClient` 内按 Provider 分支处理固定端点、地址规范化、分页、HTTP 错误、超时、取消、响应上限和结果映射;同文件定义 `ExternalKnowledgeError` |
| IPC 名称 | `src/shared/ipc-channels.ts` | 增加实例和目录操作通道 | | `src/main/knowledge/external/external-knowledge-service.ts` | 实例保存与测试、凭据加解密、目录与详情、检索测试、创建或更新绑定、请求生命周期与配置变化检查 |
| Preload | `src/preload/index.ts` | 暴露受限、类型化的外部知识库 API | | `src/main/knowledge/external/external-knowledge-store.ts` | 在已有 SQLite 连接上读写实例 JSON 和绑定 JSON |
| Main IPC | `src/main/ipc.ts` | 校验输入、调用服务、投影脱敏结果 | | `src/main/knowledge/knowledge-database.ts` | v12 迁移、知识库与绑定事务、唯一约束及删除外键 |
| 知识服务 | `src/main/knowledge/knowledge-service.ts` | 按知识库来源分派本地或外部检索 | | `src/main/knowledge/knowledge-service.ts` | 本地与外部检索分派、混合检索和总字符预算;阻止外部绑定进入本地维护操作 |
| 数据库 | `src/main/knowledge/knowledge-database.ts` | 增加实例、绑定和迁移 | | `src/shared/external-knowledge-contracts.ts` | Provider 配置、实例与绑定输入、目录、测试结果和外部引用定位契约 |
| 内部类型 | `src/main/knowledge/types.ts` | 增加 Provider、实例、绑定和标准化结果类型 | | `src/shared/knowledge-contracts.ts`、`contracts.ts`、`assistant-contracts.ts` | 现有知识结果、列表和会话引用增加外部字段 |
| 知识页面 | `src/renderer/src/KnowledgeWorkspace.tsx` | 实例管理、创建向导类型和外部详情 | | `src/shared/knowledge-reference.ts` | 预检索与 MCP 共用的引用转换和去重键 |
| 检索测试 | `src/renderer/src/KnowledgeRetrievalWorkbench.tsx` | 支持本地与外部诊断判别联合 | | `src/main/ipc.ts`、`src/preload/index.ts`、`src/shared/ipc-channels.ts` | 可信发送方检查、Zod 输入解析、类型化跨进程调用 |
| 聊天预检索 | `src/main/ipc.ts` | 统一调用 Provider-aware 检索编排 | | `src/renderer/src/ExternalKnowledge.tsx` | 实例 Modal、绑定表单、Provider 配置、外部详情与检索结果 |
| Agent 知识工具 | `src/main/agent/knowledge-mcp-gateway.ts` | 与预检索复用同一编排和引用标准化 | | `KnowledgeWorkspace.tsx`、`App.tsx`、`KnowledgeCitationDialog.tsx` | 统一列表、聊天选择、刷新及历史引用显示 |
外部 Provider 不是 `KnowledgeSourceType`。现有 `file | directory | url` 表示会进入本地 `ExternalKnowledge.tsx` 的字段改用全局 `.field`,修正 body Portal 脱离知识库祖先后
文档、分块和索引的资料来源;把远端绑定塞入该枚举会错误触发同步、分块和来源打开逻辑。 丢失 scoped 控件样式的问题。CSS 提供 560px 紧凑弹窗和粘附底部操作区;实例选择复用
既有 picker 样式并实现菜单键盘行为。没有新增 Main 模块,具体控件规则由
[UI 设计](./external-knowledge-ui-design.md#当前实现与验收边界)维护。
## 3. 模块设计 当前没有独立 Provider 文件、Adapter Registry、Normalizer、版本化能力清单或配置迁移框架。
本文件按现有三个 Main 模块描述实现,不把旧草案中的拆分方案列为待建基础设施。
建议增加: ## 3. 共享契约
```text ### 3.1 实例与绑定
src/main/knowledge/external/
├─ external-knowledge-service.ts
├─ external-knowledge-store.ts
├─ external-knowledge-errors.ts
├─ external-knowledge-http.ts
├─ external-knowledge-registry.ts
├─ external-knowledge-normalizer.ts
└─ providers/
├─ dify-adapter.ts
├─ fastgpt-adapter.ts
└─ ragflow-adapter.ts
```
职责: 实例输入包含 `id?`、`name`、`provider`、`baseUrl`、`enabled` 和
`credential: keep | replace | clear`。实例摘要返回启停、凭据状态、`probeStatus`、绑定数、
最近测试时间和错误码,不返回凭据 envelope。当前没有 `detectedVersion`、`adapterVersion`
或 `transportSecurity` 字段。
- `ExternalKnowledgeService`:实例、目录、绑定、测试和检索编排。 绑定保存 `knowledgeBaseId`、`instanceId`、`provider`、`remoteKnowledgeBaseId`、
- `ExternalKnowledgeStore`:实例和绑定持久化,凭据加解密。 `remoteName`、`commonConfig`、`providerConfig` 和 `lastVerifiedAt`。GoodBuddy 名称和描述
- `ExternalKnowledgeHttp`:超时、取消、大小限制、地址和响应读取。 保留在知识库记录。创建和更新输入都要求用户填写 `testQuery`;服务保存前重新执行检索,
- `ExternalKnowledgeRegistry`:按 Provider 返回内置 Adapter,不接受运行时插件注册。 因此先点击“测试检索”再保存会产生两次检索请求。成功零命中允许保存。
- `ExternalKnowledgeNormalizer`:统一结果边界、截断和引用。
- Adapter:构造固定端点与请求,解析 Provider 响应,声明静态配置和能力条件。
`KnowledgeService` 继续作为聊天、检索测试和 MCP 网关的统一入口。它根据 ### 3.2 配置范围
`KnowledgeBase.kind` 调用现有本地检索或 `ExternalKnowledgeService.retrieve`。
## 4. Provider Adapter | 配置 | 当前共享契约 |
| --- | --- |
| 通用 | `resultLimit` 1 至 20,默认 6;`requestTimeoutMs` 1,000 至 60,000,默认 15,000;`maxSnippetCharacters` 100 至 8,000,默认 4,000 |
| Dify | 默认分支仅包含 `provider` 和 `useDatasetDefaults: true`;覆盖分支使用严格的 `retrievalModel` 对象,包含检索方式、Top K、阈值开关和重排开关,按启用条件校验阈值、重排模型或权重 |
| FastGPT | `searchMode`、`tokenLimit`(1 至 30,000)、`similarity`、`usingRerank`;不发送查询优化参数 |
| RAGFlow | `similarityThreshold`、`vectorSimilarityWeight`、`knnTopK`(1 至 2,048)、`useKg`、`includeKnowledgeCompilation`;当前无 Rerank、Metadata 编辑字段 |
```ts Provider 必须与实例一致。Dify 加权配置还在客户端检查权重和为 1。共享查询输入上限为
type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow' 4,000 字符,Dify 客户端另按 Unicode 码点拒绝超过 250 字符的查询,不静默压缩。
interface ExternalKnowledgeAdapter<PersistedConfig, CapabilitySnapshot> { ### 3.3 结果与引用
readonly provider: ExternalKnowledgeProvider
readonly configVersion: number
normalizeBaseUrl(input: string): URL 现有 `KnowledgeRetrievalResult` 的本地 `documentId/sourceId/chunkId` 改为可选,增加
testConnection(context: AdapterContext): Promise<ConnectionTestResult> `external?: ExternalKnowledgeLocator`,没有另建一套 `UnifiedKnowledgeRetrievalResult`。
listKnowledgeBases( 外部定位包含 Provider、实例 ID、远端库 ID,以及实际返回的文档 ID、片段 ID、位置和评分。
context: AdapterContext, FastGPT 数组评分存为 `providerScores: {type, value, index?}[]`,保留顺序与类型;数值评分
input: CatalogPageInput 存为 `providerScore`,不选取数组首项冒充总分。
): Promise<CatalogPage>
getKnowledgeBase(
context: AdapterContext,
remoteKnowledgeBaseId: string
): Promise<RemoteKnowledgeBaseSummary>
probeRetrieval(
context: AdapterContext,
input: ProviderRetrieveInput<PersistedConfig>
): Promise<ProviderRetrieveResult>
retrieve(
context: AdapterContext,
input: ProviderRetrieveInput<PersistedConfig>
): Promise<ProviderRetrieveResult>
parseCapabilities(input: unknown): CapabilitySnapshot
validateConfig(
input: unknown,
capabilities: CapabilitySnapshot
): PersistedConfig
}
```
`probeRetrieval` 可以复用 `retrieve`,但必须使用用户在创建向导中明确输入的测试查询。 外部结果沿用旧结果容器的空 `channels` 和 `scores.fusedScore = 0`。该零值是结构占位,
不得生成会泄露组织信息的默认问题,不得调用 Provider 的 App 或模型问答接口,也不得 不表示质量;`toKnowledgeReference` 不把它写成引用总分。外部结果没有统一 `relevance`,
修改远端知识内容。Provider 自身记录 API 审计或计算检索用量不属于内容修改,必须由界面 不同 Provider 的原始分数不参与跨库混排。引用去重键包含外部定位和片段,不伪造本地 ID。
提前说明。
Adapter 返回数据对象,不返回 React 组件、HTML 或远端 Schema。Renderer 中的配置定义 ## 4. SQLite 与凭据
由共享的 Provider 判别联合和本地字段描述生成。
## 5. 共享契约 数据库 v12 增加两张表:
### 5.1 实例
```ts
type CredentialMutation =
| { action: 'keep' }
| { action: 'replace'; value: string }
| { action: 'clear' }
interface ExternalKnowledgeInstanceSummary {
id: string
name: string
provider: 'dify' | 'fastgpt' | 'ragflow'
baseUrl: string
enabled: boolean
credentialStatus: 'configured' | 'missing' | 'unavailable'
probeStatus:
| 'untested'
| 'catalog-ready'
| 'healthy'
| 'list-restricted'
| 'auth-failed'
| 'unreachable'
| 'incompatible'
| 'failed'
detectedVersion?: string
bindingCount: number
lastTestedAt?: string
lastErrorCode?: string
}
```
Renderer 输入使用 `CredentialMutation`,因此新密钥会在输入控件和该次 IPC 中短暂存在。
Main 返回值永远没有 `credential`、`apiKey`、`authorization` 或加密 envelope。
### 5.2 绑定
```ts
interface ExternalKnowledgeBinding {
knowledgeBaseId: string
instanceId: string
remoteKnowledgeBaseId: string
remoteName: string
remoteDescription?: string
commonConfig: ExternalKnowledgeCommonConfig
providerConfig: DifyConfig | FastGptConfig | RagflowConfig
providerConfigVersion: number
adapterVersion: number
lastVerifiedAt?: string
}
```
`providerConfig` 必须带 `provider` 判别字段,且与实例 Provider 一致。`remoteName` 是用于
显示和变化检测的快照,不是授权标识。
### 5.3 标准化结果
现有 `KnowledgeRetrievalResult` 假定结果映射到本地 `documentId/sourceId/chunkId`。需要改为
引用定位的判别联合,同时保持现有调用方可按统一字段读取片段:
```ts
type KnowledgeReferenceLocator =
| {
kind: 'local'
documentId: string
sourceId: string
chunkId: string
parentChunkId?: string
}
| {
kind: 'external'
provider: ExternalKnowledgeProvider
instanceId: string
remoteKnowledgeBaseId: string
remoteDocumentId?: string
remoteChunkId?: string
providerScore?: number
location?: string
sourceUrl?: string
metadata?: Record<string, string | number | boolean | null>
}
interface UnifiedKnowledgeRetrievalResult {
knowledgeBaseId: string
documentTitle: string
sourceDisplayName: string
snippet: string
relevance?: number
rank: number
locator: KnowledgeReferenceLocator
}
```
`remoteDocumentId` 和 `remoteChunkId` 只保存 Provider 实际返回的标识,缺失时不得从标题、
序号或内容哈希推导。`providerScore` 保留原始值,但不改名为统一 `relevance`。只有经过
单独定义和验证的归一化算法才能填写跨 Provider `relevance`。初始跨库拼装按结果配额和
稳定 Provider 顺序工作,不按原始分数混排。
所有共享 Schema 使用严格对象并限制:
- 查询不超过 4,000 字符;同时遵守 Provider 更小限制,例如 Dify 当前公开接口的
250 字符限制,超出时在发送前明确失败,不静默截断查询。
- 单次目录页不超过 100 项,总目录最多 500 项。
- 标准化结果最多 20 条,单片段最多 8,000 字符。
- 标题、ID、定位、URL 和元数据键值均设独立上限。
- Provider 原始响应总字节数设固定上限,超限立即取消读取。
## 6. 持久化
### 6.1 数据库变化
保留现有 `knowledge_bases` 表,增加:
```sql
ALTER TABLE knowledge_bases
ADD COLUMN kind TEXT NOT NULL DEFAULT 'local'
CHECK (kind IN ('local', 'external'));
```
增加实例表:
```sql ```sql
CREATE TABLE external_knowledge_instances ( CREATE TABLE external_knowledge_instances (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, value_json TEXT NOT NULL
provider TEXT NOT NULL CHECK (provider IN ('dify', 'fastgpt', 'ragflow')),
base_url TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
credential_envelope_json TEXT,
probe_status TEXT NOT NULL,
detected_version TEXT,
capabilities_json TEXT NOT NULL,
transport_security TEXT NOT NULL,
transport_risk_accepted_url TEXT,
transport_risk_accepted_at TEXT,
last_tested_at TEXT,
last_error_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
); );
```
增加绑定表:
```sql
CREATE TABLE external_knowledge_bindings ( CREATE TABLE external_knowledge_bindings (
knowledge_base_id TEXT PRIMARY KEY knowledge_base_id TEXT PRIMARY KEY REFERENCES knowledge_bases(id) ON DELETE CASCADE,
REFERENCES knowledge_bases(id) ON DELETE CASCADE, instance_id TEXT NOT NULL REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
instance_id TEXT NOT NULL remote_id TEXT NOT NULL,
REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT, value_json TEXT NOT NULL,
remote_knowledge_base_id TEXT NOT NULL, UNIQUE(instance_id, remote_id)
remote_name TEXT NOT NULL,
remote_description TEXT,
common_config_json TEXT NOT NULL,
provider_config_json TEXT NOT NULL,
provider_config_version INTEGER NOT NULL,
adapter_version INTEGER NOT NULL,
binding_status TEXT NOT NULL,
last_verified_at TEXT,
last_success_at TEXT,
last_error_code TEXT,
UNIQUE(instance_id, remote_knowledge_base_id)
); );
``` ```
已有知识库迁移为 `kind = 'local'`。外部知识库仍保留现有名称、描述和创建时间,但本地 没有向 `knowledge_bases` 增加 `kind` 列;IPC 快照根据是否存在绑定投影
`storageMode`、图谱、分块和本地检索设置不进入外部请求;后续可以在数据库重构时再移除 `KnowledgeLibrary.kind` 和 `external`。旧本地数据保留,知识库与绑定在同一事务中保存。
这些历史必填默认值,本功能不为此扩大迁移范围。 外部库沿用本地表所需的默认字段,但这些字段不进入 Provider 请求。
### 6.2 凭据 Main 启动把已有 `secureCipher` 注入 `KnowledgeService`。服务复用
`SettingsCredentialCipher` 和现有加密函数,把 envelope 放入实例 JSON;`replace` 要求
安全存储可用,`keep` 保留已有 envelope,`clear` 移除凭据。列表会尝试解密以区分
`configured/missing/unavailable`,请求前再次解密,没有独立明文凭据缓存。
复用 `SettingsCredentialCipher` 和 Electron `safeStorage`: ## 5. IPC 与生命周期
- `replace`:Main 加密新值后在一个事务中替换 envelope。 Preload 暴露实例 `List/Save/Test/SetEnabled/Delete`、目录 `List/Get`、绑定
- `keep`:不读取、不重写现有 envelope。 `Create/Update` 和 `externalRetrievalTest`。通道使用 `knowledge:external-*` 前缀,
- `clear`:确认后删除 envelope,并把实例凭据状态设为缺失。 准确名称以 `ipc-channels.ts` 为准。移除绑定复用 `deleteLibrary`,没有独立 remove 通道。
- 安全存储不可用时拒绝新增或替换,不降级为明文。
- 解密只发生在发起请求前;请求结束后不缓存明文。
实例导出、诊断和数据库普通 JSON 视图均排除 `credential_envelope_json`。 Handler 调用 `assertTrustedSender` 并解析共享输入;检索按实例 ID 在 Main 读取地址与凭据。
实例先保存为 `untested`,列表上的连接测试调用目录请求并更新摘要,不测试未保存草稿。
绑定检索测试是单独操作,不会把实例探测状态自动升级为 `healthy`。
## 7. IPC 设计 服务按实例记录 AbortController。保存实例、停用、删除以及服务关闭会取消相应请求;
调用方取消信号与总超时合并。异步测试或保存完成前比较实例配置,变化时返回
`EXTERNAL_KB_CONFIG_CHANGED`,绑定更新还检查保存期间绑定是否改变。共享 IPC 没有
`requestId` 或取消方法;Renderer 用局部 generation/active 标记丢弃旧响应,这与中止
Main 请求是两种行为。
建议增加独立通道: 删除仍被引用的实例返回 `EXTERNAL_KB_INSTANCE_IN_USE`。UI 确认后逐个调用本地
`deleteLibrary`,再删除实例;这是顺序操作,发生中途失败会刷新已移除的绑定,不是跨调用事务。
```text ## 6. HTTP 边界
knowledge:external-instances:list
knowledge:external-instances:test
knowledge:external-instances:save
knowledge:external-instances:set-enabled
knowledge:external-instances:delete
knowledge:external-catalog:list
knowledge:external-catalog:get
knowledge:external-bindings:create
knowledge:external-bindings:update
knowledge:external-bindings:remove
knowledge:external-retrieval:test
```
所有 Handler 必须: 客户端只接受 HTTP(S),拒绝地址内的用户名、密码、查询与片段;按 Provider 规范化 API
路径,仅拼接内置端点。请求使用 Bearer 认证,`redirect: 'error'`,无自动重试或公共云回退。
默认超时 15 秒,单个响应默认上限 5,000,000 字节;响应头和流式读取均检查上限,取消
期间终止读取。解析后只投影需要的有界字段,最多返回 20 条、每片段 8,000 字符。
1. `assertTrustedSender`。 当前使用 `fetcher ?? fetch`,没有单独连接全局内网兼容模式、证书例外或按地址保存传输
2. 用共享 Zod Schema 解析严格输入。 确认。2026-09-13 当前生产服务在真实 Electron 中对两个 Dify 和 FastGPT HTTPS 配置完成
3. 根据实例 ID 在 Main 读取地址和凭据,不接受 Renderer 在检索请求中重复传入。 严格证书校验,RAGFlow 使用 HTTP。此证据覆盖这些配置的请求,不表示旧 PRD 的地址确认
4. 使用稳定错误码返回脱敏错误。 或证书例外流程已实现;相关产品差异仍由功能 owner 核对。
5. 对网络操作支持 AbortSignal、总超时和响应大小限制。
目录和测试请求返回 `requestId`。Renderer 仍需校验当前类型、实例和目标与响应关联,避免 ## 7. 检索编排
旧响应覆盖新表单。
## 8. HTTP 边界 `KnowledgeService.retrieve` 查询本地绑定,存在绑定时调用
`ExternalKnowledgeService.testRetrieval`,否则执行现有本地路径。外部结果附加
`diagnostics.external`,不运行本地向量、全文或图谱检索。
### 8.1 服务地址 `retrieveMany` 对传入知识库 ID 去重并读取知识库,使用 `Promise.all` 逐库检索;只有选中
需要向量的本地库时才准备本地查询 Embedding。当前没有额外的 Provider 并发队列。单库
失败写入 `response.diagnostics.failure` 并保留空结果,成功零命中没有 failure;调用方
取消仍向上传播。当前返回 `{knowledgeBaseId, response}[]`,没有独立的 Outcome 状态联合。
- 只接受 `http:` 和 `https:`。 服务测试先按绑定的结果数与单片段限制裁剪,并限制该次片段总量为 48,000 字符。
- 拒绝用户名、密码、查询和片段。 `retrieveMany` 展平各库结果并按库内 rank 稳定排序,再轮转分配 48,000 字符的片段预算:
- Adapter 负责规范化 Provider 所需 API 根路径,避免用户填写完整操作端点。 先处理各库第 1 条,再处理各库第 2 条,同 rank 保持输入库顺序。不会先耗完整个库再轮到
- 请求只能拼接 Adapter 内置相对路径,远端目录项或错误不能改变目标 URL。 下一个库;仍没有固定的每库最低字符配额。关联的本地上下文 groups 随相应结果使用另一份
- 重定向默认拒绝;若某 Provider 的受支持部署确需重定向,只允许同源且不得跨跳转发送 48,000 字符预算。二次裁剪会标记 `context.truncated`,但第一次标准化裁剪不提供完整的
Authorization。 远端原始条数与截断诊断。
- 当前全局“内网兼容模式”决定是否允许 HTTP 和非标准 HTTPS 证书;操作开始后冻结该值。
- HTTP 或实际使用证书例外时,Main 返回 `transportSecurity` 风险状态。用户必须针对规范化
后的当前地址明确确认,才能保存实例或发送 API Key;地址变化立即使旧确认失效。
- 不进行公共云回退,不把自部署地址替换成 Provider 官方地址。
用户明确配置内网地址属于产品用途,因此不能采用禁止私网地址的通用 SSRF 规则。安全边界 聊天预检索、检索测试相关 IPC 和 `KnowledgeMcpGateway` 已使用统一检索入口。引用转换
依靠显式实例配置、Main-only 请求、固定端点、无任意方法和无远端 URL 跟随。 复用 `knowledge-reference.ts`,聊天与 MCP 仍按现有授权范围和各自输出预算处理结果。
MCP 在总预算裁剪后按库内 rank 交错选择结果,再应用工具条数和字节上限;有失败且
没有任何片段时返回工具错误。预检索只在全部库失败时报告整体失败,成功零命中伴随其他
库失败时保留降级诊断。这两种上层呈现并不完全相同。
### 8.2 请求限制 ## 8. 引用显示与历史数据
- 默认超时 15 秒,用户可在 1 至 60 秒内调整。 会话契约和数据库保存外部定位及有界片段。外部引用弹窗读取保存的片段,显示 Provider、
- 目录、详情和检索分别设置响应字节上限。 实例 ID、远端库和实际返回的定位及评分;不请求本地 `referenceContext`,不显示本地
- JSON 解析后仍执行深度、数组数量、字符串长度和未知字段策略。 `openSource` 操作。当前没有远端来源打开或引用刷新请求,`sourceUrl` 是可选契约字段,
- 默认不自动重试。用户重试或上层下一次检索会产生新请求。 三家现有映射没有填充它。
- 取消、应用关闭和实例删除立即终止相关请求。
- 日志只记录 Provider、实例 ID、操作、状态码、稳定错误码、耗时和字节数。 App 的 `source-references` 事件使用共享 `knowledgeReferenceKey` 去重传入引用,再合并
尚未被替代的旧引用,最多保留 20 条。键包含 Provider、实例、远端库、远端文档和片段 ID、
定位与片段正文;外部结果缺少本地 ID 时也不会仅因本地 ID 相同而被合并。引用弹窗按
`context.locator ?? external.location ?? locator` 选择一个定位,只显示一次。
移除绑定后历史片段仍可阅读;当前弹窗不查询实例状态,也不生成“连接已移除”标记。
实例名称没有作为独立字段存入外部 locator,不能承诺删除实例后仍显示其名称。
2026-09-13 Dify 桌面报告已验证实时搜索引用通过生产 `conversations.saveLocal` 保存、
重载并打开弹窗。会话由验证程序标记为测试记录,没有模型生成答案;最终引用去重与定位
修正后的回归状态单独记录在[实施进度](./progress.md)。
后续 `kb-full-app-live` 则通过完整 App 输入区真实发送并保存模型答案,再点击引用。
引用片段、远端文档和片段 ID 与真实检索记录一致,无伪造会话或事件;此证据覆盖 Dify
单库短答案。三家生产 IPC 生成检查还验证了 `task_events` 中的文本、引用和选中库范围。
## 9. Provider 映射 ## 9. Provider 映射
本节依据 2026-09-10 核对的官方文档、官方源码和真实实例响应。实施时应把每个受支持版本的真实响应保存为 本节区分当前客户端行为与历史接口探测。历史响应和官方文档只说明当时观察到的接口,
去敏 fixture,并以 fixture 和真实实例测试作为发布依据。 不证明当前桌面已完成验收。
官方来源与版本边界:
- Dify:[Knowledge API 与密钥范围](https://docs.dify.ai/en/api-reference/guides/knowledge)、
[列表](https://docs.dify.ai/en/api-reference/knowledge-bases/list-knowledge-bases)、
[详情](https://docs.dify.ai/en/api-reference/knowledge-bases/get-knowledge-base)、
[检索](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval)。
缺省配置顺序另对照[官方实现](https://github.com/langgenius/dify/blob/main/api/services/hit_testing_service.py)。
- FastGPT:[API 说明](https://doc.fastgpt.io/zh-CN/openapi/intro)和
[已停止维护的手工接口页](https://doc.fastgpt.io/zh-CN/openapi/dataset)。当前契约对照
[生成文档 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/openapi/core/dataset/api.ts)、
[检索处理器](https://github.com/labring/FastGPT/blob/main/projects/app/src/pages/api/core/dataset/searchTest.ts)和
[结果项 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/core/dataset/type.ts)。
云端 `/apidoc/` 本次只取得应用壳,未读取渲染后的部署 Schema。
- RAGFlow:[官网 HTTP API](https://ragflow.io/docs/dev/http_api_reference)正文未能有效提取,
采用[官方当前文档原文](https://github.com/infiniflow/ragflow/blob/main/docs/references/http_api_reference.md),并与
[v0.24.0 固定版本](https://github.com/infiniflow/ragflow/blob/v0.24.0/docs/references/http_api_reference.md)比较。
`main` 分支文档与源码会变化,不是部署版本凭据。FastGPT、RAGFlow 的实际产品版本仍未确认。
### 9.0 真实实例验证基线 ### 9.0 真实实例验证基线
2026-09-09 使用本机未入库的测试地址和 API Key 完成第一轮只读验证。验证只记录状态、 | 历史日期 | 证据与限制 |
字段名、数量和长度,不记录 API Key、知识库名称或片段正文。 | --- | --- |
| 2026-09-09 | [基线报告](./external-knowledge-probe-baseline.json):累计 107 次只读请求,最终归档脚本 19 次;当时 HTTPS 放宽证书校验 |
| 2026-09-10 | [基础报告](./external-knowledge-probe-2026-09-10-strict.json)与[扩展报告](./external-knowledge-probe-2026-09-10-extended.json):共 33 次只读请求,其中检索 17 次;HTTPS 显式开启证书校验,RAGFlow 为 HTTP |
| Provider | 实例证据 | 目录实测 | 检索实测 | 当前结论 | 历史结果包含 Dify 0.15.8 详情 405、检索零命中,Dify 1.17.0 详情成功及 3 条非空结果;
| --- | --- | --- | --- | --- | FastGPT 三种模式和重排非空;RAGFlow 图谱开关产生不同结果,但 Knowledge Compilation
| Dify `0.x` | 响应头 `x-version: 0.15.8` | 列表返回 200 和 3 项;详情 GET 返回 405 | 默认配置和显式当前配置均返回 200,结果为空 | 从目录项读取配置,手工 ID 通过检索验证;非空结果仍未验证 | 没有配置证据。FastGPT/RAGFlow 产品版本、Dify 0.15.8 非空结构及真实错误矩阵仍未确认。
| Dify `1.x` | 响应头 `x-version: 1.17.0` | 列表返回 200 和 1 项;详情 GET 返回 200 | 默认配置和显式当前配置均返回相同的 3 条非空结果 | 详情和 `records[].segment/document` 已验证;新增摘要索引、元数据、多模态和 Pipeline 能力字段 | 逐项数量、脚本命令和当时测试结果由[实施进度](./progress.md#验证证据)维护。
| FastGPT | 响应未提供版本,只能确认当前托管实例 | 基址包含 `/api`;列表和详情均返回 200,列表 `data` 直接为数组,本次 1 个 Dataset | embedding 8 条、fullTextRecall 2 条、mixedRecall 7 条、mixedRecall + Rerank 9 条 | 三种检索模式和 Rerank 均已实测;当前结果包含 `q`,未包含 `a` |
| RAGFlow | 响应未提供产品版本,Server 为 nginx,不能据此推断版本 | `GET /api/v1/datasets` 返回 200,共 28 项;17 项配置 GraphRAG,15 项有完成证据 | 选定已完成图谱的库,基础检索 0 条,`use_kg=true` 返回 1 条,Knowledge Compilation 0 条 | 图谱检索已产生区别于基础检索的结果;当前没有库具备 Knowledge Compilation 配置证据 |
2026-09-09 的 Node 测试进程设置了 `NODE_TLS_REJECT_UNAUTHORIZED=0`,因此当时 HTTPS 实例的请求 2026-09-13 新增当前产品证据:[生产验证摘要](./external-knowledge-validation-2026-09-13.json)
使用了放宽的证书校验。这只能证明接口在当前测试环境可用,不能证明严格 TLS 校验通过。 记录真实 Electron safeStorage 持久化重开、生产服务与本机 HTTP MCP,以及 Dify 桌面。
产品实现不得读取该进程级设置作为隐式默认;实例测试和保存必须按 8.1 节处理传输风险。 服务结果与 MCP 引用数分别为 Dify 配置 1 的 0/0、Dify 配置 2 的 2/2、FastGPT 的 2/2、
RAGFlow 的 1/1。报告配置编号不是版本识别,历史版本差异不据此重新认证。
2026-09-10 复测显式设置 `NODE_TLS_REJECT_UNAUTHORIZED=1`,两个 Dify 实例和 FastGPT
均通过证书校验;RAGFlow 配置为 HTTP,不涉及 TLS。基础 14 次、扩展 19 次,共 33 次只读
请求。每个实例从目录选择一个库检索,未遍历全部库;未调用聊天或生成端点,检索仍可能
使用远端 Embedding、Rerank 并记录用量。两份报告与逐项结果见[实施进度](./progress.md)。
### 9.1 Dify ### 9.1 Dify
| 操作 | 接口 | 固定使用 `GET /v1/datasets`、`GET /v1/datasets/{id}` 和
| --- | --- | `POST /v1/datasets/{id}/retrieve`。默认省略整个 `retrieval_model`,远端优先使用知识库
| 列表 | `GET /v1/datasets` | 当时配置,未保存时由远端选默认;覆盖时发送完整的本地 `retrievalModel`,不逐字段合并。
| 详情 | `GET /v1/datasets/{dataset_id}`,`0.15.8` 不支持、`1.17.0` 支持 | 结果从 `records[].segment`、文档和分数取值。
| 检索 | `POST /v1/datasets/{dataset_id}/retrieve` |
| 认证 | `Authorization: Bearer <knowledge-api-key>` |
Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或 Workflow。默认不 客户端把详情 405 分类为不兼容,绑定表单允许详情失败后继续检索验证,不实现版本探测或
发送 `retrieval_model`,沿用远端知识库配置。用户选择覆盖时,Adapter 必须一次收集并验证 自动遍历目录的详情回退。Scoped Key 可尝试手工 ID,但当前产品的真实受限密钥验收仍待完成。
当前接口要求的 `search_method`、`reranking_enable`、`top_k`、`score_threshold_enabled`。 Metadata、多模态、摘要索引、子块、附件和 Pipeline 没有进入当前简化目录或结果映射。
开启重排时再验证 `reranking_mode`,以及该模式所需的 Provider、模型或权重;缺少必填
标识时不构造请求。显式对象不会与知识库配置逐字段合并。省略整个对象时,服务端优先使用
知识库配置,未保存配置时才使用内置默认。响应从
`records[].segment`、`score`、文档名称和元数据提取标准化引用。
当前官方文档说明限定知识库范围的 Key 不能调用列表,但可访问授权 ID 的详情和检索。 官方来源:[Knowledge API](https://docs.dify.ai/en/api-reference/guides/knowledge)、
列表 403 不足以认定密钥无效;本轮测试配置均能列目录,尚未验证 Scoped Key。 [列表](https://docs.dify.ai/en/api-reference/knowledge-bases/list-knowledge-bases)、
[详情](https://docs.dify.ai/en/api-reference/knowledge-bases/get-knowledge-base)、
Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天 [检索](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval)、
诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。 [缺省配置实现](https://github.com/langgenius/dify/blob/main/api/services/hit_testing_service.py)。
两个实测版本共同返回 `retrieval_model_dict`,键包括搜索方式、Top K、阈值、Rerank 和
权重。显式原样复用该配置均返回 200,证明覆盖 envelope 可用,但 UI 编辑后的每种组合仍
需单独契约测试。
版本差异:
| 能力 | `0.15.8` | `1.17.0` | Adapter 和 UI 规则 |
| --- | --- | --- | --- |
| Dataset 详情 | GET 返回 405 | GET 返回 200 | 能力探测后调用;旧版从目录项读取配置,不能把 405 视为实例故障 |
| 非空检索结构 | 本次未命中 | 默认和显式配置均返回 3 条 | 1.17 已验证 `records[].segment.content`、`segment.document.id/name`、位置和分数映射 |
| 元数据 | 无 `doc_metadata` | 字段存在,当前库定义数为 0 | 有实际字段定义时才显示受控元数据过滤器;不提供自由 JSON |
| 多模态与附件 | 未返回能力字段 | `is_multimodal` 和 `files` 字段可用,当前库非多模态且结果无附件 | 只显示远端状态;仅在结果实际带 `files` 时显示附件引用 |
| 父子分块 | 未验证 | 返回 `child_chunks` 数组,本次为空 | 仅在实际非空时显示子块证据,不补造父子关系 |
| 摘要索引 | 未返回能力字段 | 当前库配置了 `summary_index_setting`,本次结果 `summary` 为空 | 概览显示只读状态;仅在结果实际返回摘要时展示 |
| Knowledge Pipeline | 未返回能力字段 | `pipeline_id/runtime_mode` 存在,当前库未配置 Pipeline | 只读显示,不提供运行或编辑 Pipeline 的操作 |
Dify 1.x 官方 RetrievalModel 还声明 `metadata_filtering_conditions`,并支持
`keyword_search/semantic_search/full_text_search/hybrid_search`。当前实测库没有 Metadata
字段,未执行空条件或猜测字段探测;该配置保持禁用,直到所选库详情返回可用字段定义。
### 9.2 FastGPT ### 9.2 FastGPT
| 操作 | 接口 | 固定使用 `POST /api/core/dataset/list`、`GET /api/core/dataset/detail?id=...` 和
| --- | --- | `POST /api/core/dataset/searchTest`。列表 `data[]` 保留 Dataset 与文件夹,按 `parentId`
| 列表 | `POST /api/core/dataset/list` | 读取层级,在当前父级响应内搜索和分页;文件夹只用于浏览。
| 详情 | `GET /api/core/dataset/detail?id=...` |
| 检索 | `POST /api/core/dataset/searchTest` |
| 认证 | `Authorization: Bearer <api-key>` |
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。根列表请求发送 检索接受业务码 200 的 `data.list[]` 与旧手工文档的 `data[]`,不递归猜测嵌套结构。
`parentId: null`;文件夹需按父 ID 继续读取。当前实测列表为 `data[]`,检索为 `q` 必需,`a` 仅在存在时追加;评分数组保留类型、值和可选 index,数值评分独立处理。
`data.list[]`,这是两个不同端点的结构。旧手工文档还展示检索 `data[]`,探测脚本接受 当前客户端已通过 FastGPT 真实服务与本机 HTTP MCP,均返回 2 条。报告确认结果契约与
该旧结构并单独记录 envelope;当前客户端只接受检索 `data.list[]`,旧结构尚未经过真实 引用映射有效,没有逐项归档评分包装,故旧包装和全部评分变体仍以定向测试证据为限。
部署验证。不递归猜测任意嵌套。检索映射 `datasetId`、
`text`、Token `limit`、`similarity`、`searchMode` 和 `usingReRank`。初始版本不发送查询优化
字段,避免调用 FastGPT 配置的 LLM。
当前实测响应的 `data` 包含 `list/duration/limit/searchMode/usingReRank/similarity`;结果项
包含 `q/id/datasetId/collectionId/sourceName/sourceId/chunkIndex/score`,未返回 `a`。
Adapter 以 `q` 为必需片段,`a` 仅在远端实际返回时追加,不能要求 `a` 存在。
2026-09-10 实测 `score` 为 `{type, value, index}[]`,与当前官方结果 Schema 一致;
旧手工文档中的数值分数不能作为当前统一映射依据。应保留评分类型或按明确规则选分,
不能默认取第一项作为总分。当前客户端仅提取数值分数,数组评分的映射仍待实现。
当前实例的 embedding、fullTextRecall、mixedRecall 和 mixedRecall + Rerank 均返回 200 和
非空结果,因此这四种界面组合可以启用。查询扩展会调用模型,仍不在初始界面和探测脚本中
启用。
FastGPT 4.15.0 起应优先查阅部署实例的自动生成文档,`/apidoc/devapi` 与 请求映射 `datasetId/text/limit/similarity/searchMode/usingReRank`,不发送查询扩展。
`/apidoc/systemopenapi` 的认证范围不同,Dev API 中出现不等于允许 API Key 调用。支持矩阵 Embedding、Rerank 和远端 API 审计仍可能产生用量;不调用 FastGPT 生成最终回答。
必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest`
可能记录 `SEARCH_TEST` 等远端审计并更新 API Key 用量;Embedding 和 Rerank 也可能产生 官方来源:[API 说明](https://doc.fastgpt.io/zh-CN/openapi/intro)、
费用。GoodBuddy 将这些行为作为检索成本显示,不宣称该接口没有副作用。 [旧手工接口页](https://doc.fastgpt.io/zh-CN/openapi/dataset)、
[生成 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/openapi/core/dataset/api.ts)、
[检索处理器](https://github.com/labring/FastGPT/blob/main/projects/app/src/pages/api/core/dataset/searchTest.ts)、
[结果 Schema](https://github.com/labring/FastGPT/blob/main/packages/global/core/dataset/type.ts)。
4.15.0 起应核对部署实例生成文档;历史检查只取得云端 `/apidoc/` 应用壳。
### 9.3 RAGFlow ### 9.3 RAGFlow
| 操作 | 接口 | 目录和按 ID 详情均使用 `GET /api/v1/datasets`,检索使用 `POST /api/v1/retrieval`。
| --- | --- | 请求发送 `question`、单个 `dataset_ids`、`page: 1`、`page_size: 20`、阈值、向量权重、
| 列表/详情 | `GET /api/v1/datasets` | `knn_top_k` 及显式的图谱、知识编译布尔值。HTTP 成功且业务码为 0 才读取 `data.chunks`。
| 检索 | `POST /api/v1/retrieval` | 文档名映射 `document_keyword`,位置保存返回的 `positions`,分数取原始 `similarity`。
| 认证 | `Authorization: Bearer <api-key>` |
检索映射 `question`、`dataset_ids`、`page_size`、`similarity_threshold`、 `graphEnabled` 根据有效非零 `graphrag_task_finish_at` 判断;仅配置
`vector_similarity_weight`、`knn_top_k`、重排、`use_kg` 和 `parser_config.graphrag.use_graphrag` 不算完成。知识编译依据非空
`include_knowledge_compilation`。当前文档以 `knn_top_k` 取代 `top_k`;v0.24.0 文档只声明 `compilation_template_group_id`。启用任一能力时,服务在检索前重新读取详情确认。
`top_k`,因此当前客户端不发送 `top_k` 的行为不能作为旧版本兼容承诺。 当前不保存完整能力快照,不区分图谱未配置与构建中,也不检测 Provider 版本。
`knn_top_k` 控制向量候选数量,`page_size` 控制返回数量;请求成功不能单独证明未知参数
已生效。本轮未验证候选数参数的效果,也未确定新字段首次发布版本。
HTTP 成功且业务 `code === 0` 才能解析结果。响应从 `data.chunks` 和
`data.doc_aggs` 提取片段、文档、位置和原始相似度。当前实测非空 chunk 使用
`document_keyword` 表示文档名,并包含 `id/document_id/content/positions/similarity/`
`term_similarity/vector_similarity`;初始 Adapter 应按该字段映射,不假定存在
`document_name`。
图谱规则: 官方来源:[HTTP API](https://ragflow.io/docs/dev/http_api_reference)、
[当前文档原文](https://github.com/infiniflow/ragflow/blob/main/docs/references/http_api_reference.md)、
[v0.24.0 文档](https://github.com/infiniflow/ragflow/blob/v0.24.0/docs/references/http_api_reference.md)。
旧文档只声明 `top_k`,当前客户端发送 `knn_top_k`;请求成功不证明候选数参数生效,
也不是旧部署版本兼容承诺。
- `parser_config.graphrag.use_graphrag = true` 只表示库配置了 GraphRAG;单独出现该配置不 ## 10. 验证与验收
表示图谱已经构建完成。
- `graphrag_task_finish_at` 为有效非零时间才作为图谱完成证据,不写回。
- `use_kg` 是检索时开关,只在已有图谱和受支持版本均确认后发送。
- Knowledge Compilation 与旧 GraphRAG 分别建模。
- `include_knowledge_compilation` 使用本地保存的显式布尔值,初始默认 false;即使服务端
默认 true,也不依赖字段省略获得隐式行为。
- `compilation_template_group_id` 或后续版本的等价受控字段作为 Knowledge Compilation 已
配置证据;仅仅请求返回 200 不能启用界面开关。
- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。
2026-09-09 实例 28 个库中 17 个配置 GraphRAG、15 个存在完成证据。对一个已完成库使用同一查询, 三家 Provider 已通过生产 `KnowledgeService` 与本机 HTTP MCP,真实 Electron
基础检索返回 0 条,`use_kg=true` 返回 1 条且原始相似度为 1,说明图谱开关对实际结果有 safeStorage 加解密和持久化重开成功。Dify 桌面已完成绑定、参数更新、重启、三档窗口宽度
影响。当时 28 个库均没有 Knowledge Compilation 配置证据;该请求虽返回 200 和零结果, 及历史引用检查。验证方法、原始报告边界和调用计数见[实施进度](./progress.md)及
界面仍必须禁用开关并显示“当前知识库未配置 Knowledge Compilation”。 [脱敏摘要](./external-knowledge-validation-2026-09-13.json)。本轮文档只读取报告,没有
2026-09-10 目录增至 29 项,18 项配置图谱、16 项有完成证据;所选库基础检索 2 条、 另发 Provider 或模型请求,也未改动历史探测 JSON。
图谱检索 3 条。Knowledge Compilation 开关请求返回 2 条,目录仍无配置证据,不能据此
认定编译内容参与了检索。
## 10. 检索编排 当前 Portal 与控件修改后的聚焦测试、全量回归、类型检查、lint、工作区及暂存区 diff
检查均已通过。精确计数、运行命令、耗时、执行期间文件稳定性和原失败修正记录统一见
[实施进度](./progress.md#2026-09-13-当前工作区实现与分组验证)。
```text 后续真实生成使用已保存的默认模型,凭据在 Main 通过 safeStorage 解密。三家 Provider
retrieveMany(knowledgeBaseIds, query, requestContext) 的 `always` 及 Dify `auto` 已产生有依据和引用的短答案;Dify `auto` 经进程内
├─ load and authorize GoodBuddy knowledge bases `ModelToolProvider → KnowledgeMcpGateway` 执行,不能当成本机 HTTP MCP 或远程 ACP
├─ partition local and external bindings 验收。FastGPT/RAGFlow 首轮因直接比较序列化字符串产生验证断言失败,解码请求和嵌入
├─ local: existing retrieval path 证据 JSON 后重跑通过;报告保留初始结果。完整 App 输入区的发送、真实答案及引用点击
├─ external: bounded concurrent adapter requests 仅覆盖 Dify。Dify/FastGPT 使用严格 HTTPS,RAGFlow 和模型使用已配置的 HTTP。
├─ validate and normalize each response
├─ allocate deterministic context budget
└─ return evidence plus per-library diagnostics
```
预检索和 `knowledge_search` 工具必须调用同一个 `retrieveMany`。如果只修改聊天预检索而 请求精确增量与视觉 fixture 边界见[后续验证摘要](./external-knowledge-follow-up-2026-09-13.json)
未修改 `KnowledgeMcpGateway`,`auto` 和 `always` 会产生不同能力,这是阻断发布的问题。 及[实施进度](./progress.md#2026-09-13-ui-修正与真实生成复验)。不公开模型端点、私有配置
标识、凭据和答案正文。这些短答案检查没有评估广泛问答质量。
### 10.1 上下文预算 FastGPT/RAGFlow 完整桌面专属操作、复杂或混合问答、删除和真实错误矩阵仍需补验。
Linux Host 的两条网络路径可达,但验证程序无法解密已有 SSH 凭据,在认证前返回
`E_SAFE_STORAGE_DECRYPT_FAILED`,真实远程 forced-preflight 未执行。本机 HTTP MCP
成功不覆盖该远程路径。剩余 Provider 版本和高级参数效果也不能由基础检索成功推断。
- 每个知识库先保留不超过其 `resultLimit` 的结果。 原设计的分项探测 UI、最低版本矩阵、详细诊断、地址确认和取消交互未全部落地。它们是
- 先给每个成功知识库一个最小有界配额,再按稳定顺序分配剩余字符。 待核对的产品差异,不能以补建 Adapter Registry 或版本框架代替验收决策。
- 不用不同 Provider 原始分数决定跨库优先级。
- 片段截断在 Main 完成并在引用中标记。
- 相同实例的多个绑定仍按绑定分别请求,除非 Provider 明确支持且 Adapter 实现等价的多
Dataset 请求;合并不得改变每个绑定的配置和诊断。
### 10.2 失败结果 `scripts/external-knowledge-probe.mjs` 仍可只读探测列表、详情、检索;`--extended` 增加
Dify 配置覆盖、FastGPT 重排和 RAGFlow 两种开关。报告区分成功零命中与失败,不输出
凭据或业务正文。脚本不打包到桌面或 Agent,脚本执行本身不覆盖 UI、IPC、存储和引用链路。
返回结构包含每个知识库的: ## 11. 需求追踪
```ts | 需求 | 当前实现落点 | 待验收重点 |
interface KnowledgeRetrievalOutcome {
knowledgeBaseId: string
status: 'success' | 'empty' | 'failed' | 'cancelled'
durationMs: number
resultCount: number
errorCode?: ExternalKnowledgeErrorCode
truncated: boolean
}
```
至少一个 `success` 或 `empty` 表示检索编排完成;全部 `failed/cancelled` 时上层显示整体失败。
## 11. 引用与来源打开
外部结果不能进入依赖本地数据库的 `referenceContext` 和 `openSource` 路径。
- 引用详情直接使用请求时保存的标准化片段和定位。
- Main 可以按远端片段 ID 提供一次只读重新读取,但初始版本不要求。
- Provider 返回 URL 只作为不可信元数据保存。没有独立 URL 规则和真实实例验证前,
Renderer 不显示打开链接操作。
- 活动和会话持久化保存标准化引用,其中包含实际用于回答的有界远端片段;这属于引用
留存,不是批量同步或本地索引。删除会话或对应活动时按现有生命周期删除该副本。
- 实例删除后,历史引用仍显示 Provider、知识库、文档名称和已保存片段,但标记连接已移除。
## 12. 支持矩阵与版本变化
每个 Adapter 维护明确的最低版本、已验证版本和 `adapterVersion`。连接测试优先读取可靠的
版本接口或响应头;无法识别版本时,只允许经真实请求验证过的基础列表和检索字段,高级字段
保持不可用。
Adapter 可以保存少量受控事实,例如“目录接口可用”“详情表明已构建图谱”,但不尝试从
远端拼出完整 capability manifest,也不计算 capability fingerprint。
- `adapterVersion` 相同且实例仍在支持矩阵内:解析已保存配置并执行。
- Adapter 升级:使用明确的配置迁移或把绑定标记为 `config-invalid`,不猜测字段迁移。
- 实例版本离开支持矩阵:停止发送高级字段,并要求重新测试和确认。
- 新能力出现:保持默认关闭,用户主动配置后才发送。
- RAGFlow `use_kg` 还要求当前远端知识库存在已构建图谱的证据。
## 13. 安全与隐私
- API Key 在用户输入和保存 IPC 中短暂存在,随后使用系统安全存储加密;保存后不返回
Renderer,也不进入 Runtime、日志和普通元数据。
- 请求不附带完整会话、附件、本地知识库证据、系统提示、模型配置或其他 Provider 结果。
- Provider 结果使用与本地知识证据相同的不可信上下文标记。
- Adapter 拒绝响应中的原型污染键、过深对象、超长数组和非有限数值。
- 远端错误正文只用于 Main 内映射,日志与 UI 使用稳定错误码和短消息。
- 外部知识库不能扩大当前对话显式选择的 GoodBuddy 知识库 ID。
- Ask 与 Execute 对知识检索均为只读,Execute 不获得额外 Provider 权限。
- Provider 配置中的模型 ID、背景文本和 Metadata 条件按独立长度限制验证,不能携带凭据。
- HTTP 或证书例外需要按当前规范化地址确认凭据传输风险,地址变化后重新确认。
## 14. 测试
### 14.1 契约测试
- 每个 Provider 的列表、详情、检索成功和错误 fixture。
- 未知字段、缺字段、错误类型、超长内容、非有限分数和超大响应。
- Dify 查询长度、FastGPT 文件夹过滤、RAGFlow 图谱和 Knowledge Compilation 字段。
- Provider 配置判别联合拒绝类型错配和未知字段。
### 14.2 安全测试
- 保存完成后的 Renderer 快照、IPC 返回值、日志、错误、活动和导出中不存在明文 API Key;
`replace` 输入只在当前表单和单次 IPC 请求中短暂存在。
- `keep/replace/clear` 凭据语义和安全存储不可用路径。
- 地址中的凭据、查询、片段、危险 scheme、跨源重定向和远端 URL 注入被拒绝。
- 取消、超时、响应大小上限和应用关闭释放请求。
### 14.3 集成测试
- 实例保存、目录读取、手工 ID、绑定创建、编辑和本地移除。
- 同一远端目标重复绑定被拒绝。
- 删除实例的引用保护和级联本地移除确认。
- 本地与外部 `retrieveMany` 部分成功、全部失败和确定性上下文预算。
- 聊天 `always` 与 Agent `knowledge_search` 使用同一 Provider-aware 编排。
- 历史引用在实例或绑定移除后仍可读。
### 14.4 UI 测试
- 类型切换、异步旧响应、目录搜索、刷新和手工模式。
- 两栏与窄窗口堆叠、键盘、焦点恢复和离开保护。
- Provider 字段显隐、失效配置、RAGFlow 图谱说明和恢复默认。
- 外部详情不出现导入、同步、分块和本地图谱操作。
- 错误、零结果、部分失败和 Provider 引用显示。
### 14.5 真实实例验收
每个 Provider 至少选择一个明确版本的自部署实例,记录:
- 版本、部署方式和认证范围。
- 列表与检索的请求/响应 fixture,移除凭据和业务正文。
- 默认与专属配置是否真实生效。
- 401、403、404、429、超时和取消行为。
- 查询、片段、引用和诊断的实际上限。
Mock 或公开文档通过不能代替真实实例验收。
2026-09-09 基线已完成 Dify `0.15.8`/`1.17.0` 目录、详情差异和 1.17 非空检索结构,
2026-09-10 补充验证了 HTTPS 实例的严格 TLS。FastGPT/RAGFlow 产品版本、真实实例
401/403/404/429、超时和取消尚未完成,因此不满足本节完整验收条件。
### 14.6 可重复探测脚本
仓库提供 `node scripts/external-knowledge-probe.mjs`。默认从进程环境变量读取
`GOODBUDDY_<PROVIDER>_BASE_URL/API_KEY`,本地开发也兼容当前两行格式的 `.env.dify`、
`.env.fastgpt` 和 `.env.ragflow`;Dify 文件还支持多个 `标签/URL/API Key` 三行配置块,用于
同一次报告比较多个版本。输出不包含地址、凭据、远端 ID、名称、查询和正文。
```text
node scripts/external-knowledge-probe.mjs
node scripts/external-knowledge-probe.mjs --provider=ragflow --extended
node scripts/external-knowledge-probe.mjs --extended --output=<report.json>
```
默认模式覆盖目录、详情和基础检索。`--extended` 额外执行 Dify 当前配置覆盖、FastGPT
Rerank、RAGFlow GraphRAG 和 Knowledge Compilation 检索。脚本只有固定 GET/POST 读取与
检索端点,不实现或调用远端创建、更新、上传和删除。
三家报告均将请求成功与命中数量分开:HTTP 必须成功,FastGPT 业务 `code` 必须为 `200`、
RAGFlow 必须为 `0`,并分别校验 Dify `records`、FastGPT `data` 或 `data.list`、RAGFlow
`data.chunks` 数组结构,才记录检索 `success: true` 和 `count`。成功的空数组
记录 `count: 0`;HTTP、业务、响应结构、JSON 解析或网络失败记录 `success: false` 与
固定失败类别,不用零命中代替失败。目录失败时不继续调用详情和检索。
`features.searchModes` 只包含基础检索实际成功的模式;`rerankProbed` 表示实际尝试,
`rerankRequestSucceeded` 表示请求成功,结果 `rerankEnabled` 单独记录远端实际返回的
`usingReRank` 布尔值。FastGPT 另记录检索 envelope、分数类型和分数数组项结构;
operation 保留 HTTP 状态、安全业务码及白名单网络错误码,不记录远端错误消息。
历史基线保留原始证据,不追填未观测的成功字段。
`tests/external-knowledge-probe-fastgpt.test.ts` 和 `tests/external-knowledge-probe-outcomes.test.ts`
使用内存 fetch fixture 运行真实 CLI,覆盖错误、成功零命中和非空结果,不连接外部知识服务。
### 14.7 CRUD 验收边界
| 对象 | Create | Read | Update | Delete | 验收方式 |
| --- | --- | --- | --- | --- | --- |
| GoodBuddy 外部实例 | 是 | 是 | 是 | 是 | SQLite、凭据存储、IPC 和 Renderer 集成测试 |
| GoodBuddy 外部绑定 | 是 | 是 | 是 | 是 | SQLite、统一知识库列表、检索和删除确认测试 |
| Provider 远端知识库与内容 | 否 | 目录、详情、检索 | 否 | 否 | 探测脚本确认只有只读端点;代码审查确认无写端点 |
FastGPT 等 Provider 的公开 API 确实提供远端知识库、集合和数据 CRUD,但这不属于当前产品
接入。若未来要验证远端 CRUD,必须先修改 PRD,并使用可删除的专用测试实例或命名空间;
不能在本轮三个既有实例上执行破坏性测试。
## 15. 实施顺序
这里的顺序只用于控制代码依赖,不裁剪产品设计:
1. 建立共享判别契约、数据库迁移和凭据存储。
2. 实现 HTTP 边界、错误映射和 Adapter Registry。
3. 逐个实现三种 Provider,并用 fixture 完成契约测试。
4. 实现实例管理和目录 IPC。
5. 扩展创建向导、统一列表和外部详情。
6. 扩展检索测试、引用和 `retrieveMany`。
7. 接入聊天预检索与 `KnowledgeMcpGateway`。
8. 完成真实实例、安全、响应式和跨平台验收。
## 16. 发布条件
- 三种 Provider 的受支持版本和限制已在产品界面或发布说明中可查。
- 真实实例列表、绑定、检索和错误矩阵均通过。
- 没有写入远端知识内容的代码路径。
- API Key 泄漏测试、地址边界和响应大小测试通过。
- 本地知识库创建、同步、检索、图谱和引用回归测试通过。
- `auto`、`always`、检索测试和 MCP 知识工具得到一致的外部知识能力。
## 17. 需求追踪
| 需求 | 技术落点 | 验证 |
| --- | --- | --- | | --- | --- | --- |
| `EK-FR-01` 至 `EK-FR-03` | 实例契约、Store、实例 IPC、凭据和测试状态 | 实例集成测试、安全测试 | | `EK-FR-01` 至 `EK-FR-05` | 实例 Service/Store、目录 Client、IPC 与管理表单 | 服务已验证;补充 FastGPT/RAGFlow 桌面和受限目录手工 ID |
| `EK-FR-04`、`EK-FR-05` | Adapter 目录与详情接口、目录 IPC | Provider fixture、手工 ID 集成测试 | | `EK-FR-06` 至 `EK-FR-10` | 共享 Provider 配置、绑定事务、统一列表与两栏表单 | Dify 参数与布局已验证;其他 Provider 专属参数及未提供字段待核对 |
| `EK-FR-06` 至 `EK-FR-09` | Provider 判别联合、Adapter 配置版本、Renderer 本地字段定义 | 契约测试、UI 字段显隐测试 | | `EK-FR-11` 至 `EK-FR-14` | `retrieveMany`、预检索、MCP、共享引用与 failure 字段 | 三家 IPC 短答案与 Dify 完整输入区引用已验证;混合失败、广泛问答与远程待补 |
| `EK-FR-10`、`EK-FR-11` | `KnowledgeBase.kind`、绑定表、统一 `retrieveMany` | 本地与外部混合检索测试 | | `EK-FR-15` | 唯一约束、外键保护、顺序本地删除 | 中途失败刷新、远端内容不变 |
| `EK-FR-12`、`EK-FR-13` | 引用定位判别联合、结果标准化、检索测试 IPC | 引用持久化与 UI 测试 | | `EK-FR-16` | Main 固定端点、有界请求与数据说明 | 当前桌面传输行为与 PRD 差异 |
| `EK-FR-14` | 稳定错误码、逐库 Outcome、取消和超时 | 错误 fixture、部分失败测试 |
| `EK-FR-15` | 外键限制、绑定本地删除、无远端写接口 | 删除生命周期测试 |
| `EK-FR-16` | Main-only HTTP、固定端点、传输风险确认 | 地址、证书和凭据泄漏测试 |
@@ -4,13 +4,90 @@
| 项目 | 内容 | | 项目 | 内容 |
| --- | --- | | --- | --- |
| 状态 | 设计稿 | | 状态 | Portal 与控件已修正,Dify 完整 App 真实问答及当前自动回归已通过 |
| 版本 | 0.1 | | 版本 | 0.2 |
| 日期 | 2026-09-03 | | 日期 | 2026-09-13 |
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | | 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | | 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| 全局规范 | [GoodBuddy UI 设计系统](../../../UI-DESIGN.md) | | 全局规范 | [GoodBuddy UI 设计系统](../../../UI-DESIGN.md) |
## 当前实现与验收边界
`KnowledgeWorkspace.tsx` 已接入 `ExternalKnowledge.tsx`,不再处于只有界面草案的阶段。
当前操作与控件如下;后续编号章节保留目标布局、文案和验收要求,其中超出本节的内容
仍待核对,不能作为已实现或已通过桌面验收的描述。
| 区域 | 当前实现 |
| --- | --- |
| 页面入口与列表 | 标题区有“管理外部实例”和新建操作;本地和外部同一列表,按显示名、远端名、Provider、实例名搜索,原生 Select 筛选来源 |
| 实例管理 | 560px 紧凑 Modal 内切换实例列表、编辑表单和删除确认;标题随状态变化,行内提供测试、编辑、启停和移除,显示地址 origin、凭据、探测状态、绑定数与本地化测试时间 |
| 实例保存与测试 | 新建直接显示密码输入;编辑才提供 keep/replace/clear,清除需要确认。眼睛图标切换显隐并提供可访问名称和按下状态。保存后回列表测试目录,连接说明默认折叠;HTTP 地址下显示传输说明 |
| 创建类型 | 共享 `SegmentedControl` 选择本地、Dify、FastGPT、RAGFlow;本地继续使用现有创建向导,外部挂载独立绑定表单 |
| 来源选择 | 实例使用复用既有 picker 样式的富信息菜单,条目显示名称、地址 origin 与凭据状态;目录使用搜索框、目标按钮、刷新和分页,FastGPT 文件夹可进入与返回根目录;共享分段控件切换手工 ID |
| 配置 | 常规窗口来源与参数两栏,宽度不超过 959px 时单栏;参数使用数字输入、原生 Select 与共享滑动 Switch 样式,没有 Slider 或自由 JSON 编辑器 |
| 测试与保存 | 用户填写查询,测试成功后可保存,零命中允许添加;保存会再次发出检索请求。查询、目标或参数改变会使测试失效,更换现有绑定目标要求确认 |
| 外部详情 | 共享 `PageTabs` 提供概览、检索配置和检索测试;概览显示远端 ID、推导状态和绑定验证时间,可刷新详情、用于对话、重新绑定、管理实例或移除 |
| 参数编辑 | 复用绑定表单,保存按钮为“保存检索参数”;恢复默认只重置表单。配置页签访问后保持挂载,切换详情页签保留草稿 |
| 检索结果 | 外部组件显示总耗时、返回数量、片段、定位、实际 ID 和原始评分,包括 FastGPT 评分数组;不显示本地全文、向量、图谱通道或本地上下文操作 |
| 聊天与引用 | 统一选择器显示 Provider、实例与状态;外部引用打开保存片段,显示实例 ID 和远端定位,不请求本地上下文或打开原始来源 |
原 `knowledge-field` 的控件样式依赖知识库工作区祖先,实例 Modal 经 `createPortal` 挂到
body 后丢失该作用域。当前 `Field` 使用全局 `.field` 和标签 span,密码也复用同一字段
结构,确保 Portal 内的字体、输入边框、背景和焦点样式与应用一致。弹窗宽度为
`min(560px, 100%)`,内容可滚动;操作区粘附底部,范围徽标靠左、按钮靠右,滚动留白
避免键盘焦点被底部操作区遮住。通用参数、Provider 参数和认证使用 fieldset/legend 分组。
Switch 按标签文字在左、滑动开关在右排列,保持 `role="switch"`。实例菜单支持方向键
打开与移动、Home/End、跳过禁用项、Enter 选择和 Escape 返回触发按钮;目录空状态使用
共享 `EmptyState`,无搜索结果可清空查询,无目录可切换手工 ID。已选目标悬停不覆盖
选中背景,保存和测试分别显示对应忙碌文案。
保存、删除等成功信息及实例管理中的异步错误使用应用通知;绑定测试、详情刷新和删除
中途失败保留就地恢复所需的错误。实例有绑定时,确认后依次移除本地绑定再删实例;中途
失败刷新已完成的移除,远端内容不变。
当前已实现过期响应丢弃、Modal 焦点管理、`PageTabs`、标记了 `role="switch"` 的开关和
窄窗口 CSS。2026-09-13 Dify 已在当前生产 BrowserWindow、Main、Preload、Renderer 和
IPC 上完成实例保存、目录选择、检索后绑定、参数更新、重启及历史引用检查,详见
[脱敏验证摘要](./external-knowledge-validation-2026-09-13.json)。验证程序仅在 Main 的实例
保存 Handler 边界替入已有密钥,未将真实密钥放入 Renderer。
1440、900、700 窗口宽度对应报告 viewport 1439、899、699,均无 body 横向溢出。
宽屏配置两栏、窄屏单栏,窄屏列表可见;原生 ArrowRight 可以激活检索页签。已保存并
重开的参数为返回 2 条、超时 15 秒、单片段 200 字符。生产搜索返回两条引用,验证程序
保存带测试标记的会话后重载,引用弹窗显示 Provider、片段和快照说明,焦点在弹窗内,
没有本地上下文或打开来源操作。此检查没有生成模型答案。
上述早期检查没有生成答案。后续完整 App 已从实际输入区选择 Dify 知识库并点击发送,
得到已保存的真实模型短答案,再点击引用核对片段及远端文档/片段 ID;没有伪造消息或
引用。浅深主题均检查了真实 Portal 编辑器的共享字体、背景、560px 宽度与无横向溢出。
另有真实 Electron 视觉 fixture 覆盖浅深主题、四档尺寸及原生键盘操作;其 IPC 是假数据,
只用于界面、焦点和布局检查。三家生成的生产 IPC 验证中,引用展示使用隔离 DOM;只有
Dify 完成了完整 App 的发送与点击引用。计数与方法边界见
[实施进度](./progress.md#2026-09-13-ui-修正与真实生成复验)和
[后续摘要](./external-knowledge-follow-up-2026-09-13.json)。当前 UI 修正后的聚焦测试、
全量回归、类型检查、lint 与工作区/暂存区 diff 检查均已通过。App 测试已按实际可访问
富信息菜单交互替换旧 Select 操作,精确计数由实施进度维护。FastGPT/RAGFlow 完整桌面专属操作和
广泛问答质量仍待验收。
与下文目标设计相比,当前还存在以下边界:
- 没有保存前按地址传输风险确认,也没有接入独立证书例外设置;只有传输说明。
- 没有目录或检索的 IPC 取消入口;丢弃旧响应不等于中止网络请求。测试期间绑定表单整体
禁用,不能把其“取消”按钮描述为正在进行的请求可随时取消。
- Dify 版本、Metadata、摘要、多模态和 Pipeline 状态没有进入当前界面;RAGFlow 只接收
图谱完成与知识编译可用布尔证据,没有三态构建展示或版本支持矩阵。
- 列表和概览没有持久化的最近成功检索时间;概览显示 `lastVerifiedAt`。当前也没有自动
`remote-missing/config-invalid` 状态、完整裁剪诊断或历史引用“连接已移除”标签。
- 没有覆盖所有关闭、类型切换和手工模式切换的未保存草稿离开确认;实例列表使用可换行
列表而非表格,未单设 720px 表格转换规则。
## 目标设计与待验收要求
以下保留既有产品布局和交互目标。当前事实以上节为准;历史 Provider 探测用于解释能力
边界,不作为下述界面已经呈现或已通过产品验证的证据。
## 1. 信息架构 ## 1. 信息架构
外部知识库不增加左侧主导航和知识库一级页签。现有知识库页面结构保持: 外部知识库不增加左侧主导航和知识库一级页签。现有知识库页面结构保持:
@@ -82,7 +159,7 @@ RAGFlow
## 4. 管理外部实例 ## 4. 管理外部实例
点击“管理外部实例”打开大尺寸 Modal。Modal 使用标题、可滚动内容和固定操作区,遵循 点击“管理外部实例”打开最大 560px 的紧凑 Modal。Modal 使用标题、可滚动内容和粘附底部操作区,遵循
全局焦点陷阱、Escape、背景隔离和焦点恢复规则。 全局焦点陷阱、Escape、背景隔离和焦点恢复规则。
```text ```text
@@ -237,6 +314,14 @@ Rerank 用量。`
右侧配置固定分为“通用配置”和“{Provider} 配置”,不创建嵌套卡片。高级字段可以使用 右侧配置固定分为“通用配置”和“{Provider} 配置”,不创建嵌套卡片。高级字段可以使用
一个折叠区,但核心检索方式、阈值和 RAGFlow 图谱检索保持直接可见。 一个折叠区,但核心检索方式、阈值和 RAGFlow 图谱检索保持直接可见。
第一版遵循 [PRD 产品决策](./external-knowledge-prd.md#2-产品决策)的只读范围。面板固定说明:
`参数仅保存在 GoodBuddy,用于此绑定的后续检索,不会修改远端知识库设置。`
编辑页保存按钮使用“保存检索参数”;恢复默认仅重置当前表单,保存后作用于当前绑定。
远端索引、模型、图谱构建和 Pipeline 配置只展示状态,不提供编辑或执行操作。
Dify 的“使用知识库默认配置”开启时说明:`每次检索沿用远端当前设置,远端设置变化会影响结果。`
关闭后,客户端显式检索参数随请求发送,不写回远端默认配置。
控件选择: 控件选择:
- 开关:图谱检索、Score 阈值启用、Rerank、使用 Dify 知识库默认配置。 - 开关:图谱检索、Score 阈值启用、Rerank、使用 Dify 知识库默认配置。
@@ -267,11 +352,11 @@ Rerank 用量。`
| Dify `0.15.8` | 使用知识库默认配置;展示 `retrieval_model_dict` 中已出现的检索方式、Top K、阈值和 Rerank 配置 | Metadata、多模态、摘要和 Pipeline 状态 | 目录项能力;该版本详情 GET 返回 405,不能把详情失败显示成实例不可用 | | Dify `0.15.8` | 使用知识库默认配置;展示 `retrieval_model_dict` 中已出现的检索方式、Top K、阈值和 Rerank 配置 | Metadata、多模态、摘要和 Pipeline 状态 | 目录项能力;该版本详情 GET 返回 405,不能把详情失败显示成实例不可用 |
| Dify `1.17.0` | 默认配置、当前完整配置;详情中的文档统计、摘要索引、多模态、Metadata 和 Pipeline 只读状态 | 当前库没有字段定义时禁用 Metadata 条件;远端 Pipeline 和摘要配置不可编辑 | 详情 GET 和 3 条非空检索结果已实测;`child_chunks/files/summary` 仅按结果实际存在显示 | | Dify `1.17.0` | 默认配置、当前完整配置;详情中的文档统计、摘要索引、多模态、Metadata 和 Pipeline 只读状态 | 当前库没有字段定义时禁用 Metadata 条件;远端 Pipeline 和摘要配置不可编辑 | 详情 GET 和 3 条非空检索结果已实测;`child_chunks/files/summary` 仅按结果实际存在显示 |
| FastGPT 当前实例 | embedding、全文召回、混合召回;Rerank | 查询扩展、扩展模型和背景文本 | 三种模式和 Rerank 均有 200 非空实测;查询扩展会调用模型,初始版本不启用 | | FastGPT 当前实例 | embedding、全文召回、混合召回;Rerank | 查询扩展、扩展模型和背景文本 | 三种模式和 Rerank 均有 200 非空实测;查询扩展会调用模型,初始版本不启用 |
| RAGFlow 当前实例 | 相似度、向量权重、KNN Top K;具备完成证据时启用图谱检索 | 没有独立配置证据时禁用 Knowledge Compilation;Metadata 条件仍隐藏 | `graphrag_task_finish_at` 有效才启用图谱;`compilation_template_group_id` 有效才启用 Knowledge Compilation | | RAGFlow 当前实例 | 基础检索;具备完成证据时启用图谱检索 | 没有独立配置证据时禁用 Knowledge Compilation;Metadata 条件仍隐藏 | 相似度、向量权重和候选数请求已被接受,但未单独验证参数效果;候选数参数需按部署版本适配,详见技术设计;`graphrag_task_finish_at` 有效才启用图谱;`compilation_template_group_id` 有效才启用 Knowledge Compilation |
RAGFlow 列表项应区分三个图谱状态:“未配置”“构建中或未完成”“可用于图谱检索”。不能 RAGFlow 列表项应区分三个图谱状态:“未配置”“构建中或未完成”“可用于图谱检索”。不能
仅因 `parser_config.graphrag.use_graphrag = true` 就显示为可用。当前实测中 17 个库配置了 仅因 `parser_config.graphrag.use_graphrag = true` 就显示为可用。实测数量见
GraphRAG,只有 15 个存在完成证据。 [实施进度](./progress.md#验证证据),界面按当前远端知识库的配置和完成状态判断。
Knowledge Compilation 的请求被服务器接受但当前没有库提供配置证据,因此开关显示为禁用, Knowledge Compilation 的请求被服务器接受但当前没有库提供配置证据,因此开关显示为禁用,
说明为“当前知识库未配置 Knowledge Compilation”。返回 200 或零结果都不能改变此状态。 说明为“当前知识库未配置 Knowledge Compilation”。返回 200 或零结果都不能改变此状态。
@@ -0,0 +1,96 @@
{
"date": "2026-09-13",
"kind": "sanitized-validation-summary",
"scopeNote": "Historical evidence before subsequent portal/control fixes and real-generation follow-up; not final regression evidence for the newer UI",
"followUpEvidence": "external-knowledge-follow-up-2026-09-13.json",
"sources": [
"kb-strict-validation/report.json",
"kb-desktop-ui-validation-20260913/report.json",
"goodbuddy-native-controls-fix-full.log",
"goodbuddy-native-controls-fix-full.result.json"
],
"automatedChecks": {
"status": "passed-before-subsequent-ui-follow-up",
"authoritativeCounts": "progress.md",
"fullApp": { "status": "passed", "evidence": "owner handoff" },
"fullSuite": {
"command": "npm test -- --maxWorkers=1 --reporter=verbose",
"status": "passed",
"exitCode": 0,
"evidence": ["goodbuddy-native-controls-fix-full.log", "goodbuddy-native-controls-fix-full.result.json"]
},
"typecheck": { "command": "npm run typecheck", "status": "passed", "evidence": "owner handoff after final code edits" },
"lint": { "command": "npm run lint", "status": "passed", "evidence": "owner handoff after final code edits" },
"diffCheck": { "status": "passed", "evidence": "owner handoff after final code edits" },
"sourceStability": {
"files": ["src/renderer/src/App.tsx", "src/renderer/src/App.test.tsx"],
"hashesStableThroughoutFullRun": true,
"evidence": "owner handoff"
},
"priorFailures": "resolved; exact intermediate and final counts are maintained only in progress.md"
},
"strictService": {
"boundary": "Production KnowledgeService/client and local HTTP MCP in real Electron; no interactive renderer or authenticated Linux execution",
"electronVersion": "43.2.0",
"safeStorageAvailable": true,
"safeStorageRoundTrip": true,
"retrievalHttpAttempts": 12,
"retrievalHttpResponses": 12,
"profiles": [
{ "provider": "dify", "profile": 1, "scheme": "https:", "strictTls": true, "catalogPassed": true, "detailHttpStatus": 405, "bindingPassed": true, "persistedCredentialReopened": true, "responseSchemaValid": true, "retrievalResults": 0, "mcpToolSuccess": true, "mcpReferences": 0, "citationMappingValid": true, "retrievalHttpAttempts": 3 },
{ "provider": "dify", "profile": 2, "scheme": "https:", "strictTls": true, "catalogPassed": true, "detailHttpStatus": 200, "bindingPassed": true, "persistedCredentialReopened": true, "responseSchemaValid": true, "retrievalResults": 2, "mcpToolSuccess": true, "mcpReferences": 2, "citationMappingValid": true, "retrievalHttpAttempts": 3 },
{ "provider": "fastgpt", "profile": 1, "scheme": "https:", "strictTls": true, "catalogPassed": true, "detailHttpStatus": 200, "bindingPassed": true, "persistedCredentialReopened": true, "responseSchemaValid": true, "retrievalResults": 2, "mcpToolSuccess": true, "mcpReferences": 2, "citationMappingValid": true, "retrievalHttpAttempts": 3 },
{ "provider": "ragflow", "profile": 1, "scheme": "http:", "strictTls": null, "catalogPassed": true, "detailHttpStatus": 200, "bindingPassed": true, "persistedCredentialReopened": true, "responseSchemaValid": true, "retrievalResults": 1, "mcpToolSuccess": true, "mcpReferences": 1, "citationMappingValid": true, "retrievalHttpAttempts": 3 }
]
},
"desktop": {
"provider": "dify",
"phase": "completed",
"boundary": "Production electron-vite Main, preload, BrowserWindow, renderer and IPC; existing provider key substituted in Main at instance-save handler boundary",
"callsByKind": { "catalog": 4, "detail": 4, "retrieval": 6 },
"totalReadOnlyCalls": 14,
"allHttpStatus200": true,
"checks": [
"Instance saved through production IPC",
"Catalog selected and binding saved after live retrieval",
"Persisted binding reopened in isolated desktop profile",
"Local parameters saved through production binding update",
"Native ArrowRight moved focus and activated retrieval tab",
"Live search references persisted and reopened in production citation dialog"
],
"measuredViewportWidths": [1439, 899, 699],
"bodyOverflowAtMeasuredWidths": [0, 0, 0],
"configColumnsAtMeasuredWidths": { "1439": 2, "699": 1 },
"mobileListVisible": true,
"savedCommonConfig": { "resultLimit": 2, "requestTimeoutMs": 15000, "maxSnippetCharacters": 200 },
"resultCount": 2,
"localContextButtons": false,
"window": { "sandbox": true, "contextIsolation": true, "nodeIntegration": false },
"encryptedCredential": { "scheme": "electron-safe-storage", "decryptMatches": true, "plaintextAbsent": true },
"citationPersisted": { "count": 1, "referenceCount": 2 },
"citationDialog": { "snapshot": true, "provider": true, "snippet": true, "openSource": false, "focusInside": true },
"citationMethod": "Production knowledge.search returned live references; harness saved a labeled test transcript through conversations.saveLocal and opened it after reload. No model answer generated",
"isolatedProfileAndBuildRemoved": true
},
"remote": {
"lanTcpConnected": true,
"vpnTcpConnected": true,
"savedCredentialSource": "unreadable",
"hostKeyState": "verified",
"blocker": "E_SAFE_STORAGE_DECRYPT_FAILED",
"forcedPreflightLive": "blocked-before-authenticated-ssh",
"authenticatedSshAttempts": 0
},
"sessionAccounting": {
"source": "Strict report historical bounds plus desktop report; confirmed by owner handoff",
"unsurfacedFirstPassRetrievalMinimum": 0,
"unsurfacedFirstPassRetrievalMaximum": 12,
"earlierConfirmedRetrievalCalls": 12,
"strictRetrievalCalls": 12,
"desktopRetrievalCalls": 6,
"totalRetrievalMinimum": 30,
"totalRetrievalMaximum": 42,
"generatedModelAnswers": 0,
"limitation": "First pass retained no diagnostics, so its exact retrieval count cannot be reconstructed. Provider-side embedding, reranking and audit usage is not counted as generated answers"
}
}
+4
View File
@@ -13,6 +13,10 @@
## 1. 背景 ## 1. 背景
本文负责本地检索与分块增强。当前工作区另已接入外部实例、绑定和统一检索,其产品范围见
[外部知识库 PRD](./external-knowledge-prd.md),实现与待验收状态见[实施进度](./progress.md)。
外部绑定不进入下述本地分块、向量索引和图谱维护流程。
GoodBuddy 已具备本地多知识库、文件与目录同步、网页导入、SQLite FTS5、 GoodBuddy 已具备本地多知识库、文件与目录同步、网页导入、SQLite FTS5、
OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来源引用。现有实现 OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来源引用。现有实现
优先建立了本地数据主权、安全边界和跨 Runtime 工具授权,但用户仍难以稳定 优先建立了本地数据主权、安全边界和跨 Runtime 工具授权,但用户仍难以稳定
+168 -5
View File
@@ -2,7 +2,8 @@
## 已验证实现 ## 已验证实现
截至 2026-09-05: 截至 2026-09-05 的本地知识库验证记录如下。外部知识库当前实现和验证范围见后文
2026-09-13 记录。
- 知识库选择按 Conversation 持久化,新对话默认空范围。 - 知识库选择按 Conversation 持久化,新对话默认空范围。
- 创建流程默认只展示名称和描述,高级存储与图谱设置折叠。 - 创建流程默认只展示名称和描述,高级存储与图谱设置折叠。
@@ -15,6 +16,156 @@
## 验证证据 ## 验证证据
### 2026-09-13 当前工作区实现与分组验证
三家 Provider 已通过生产服务、本机 HTTP MCP 与生产 IPC 的短答案生成检查,Dify 另已
从完整 App 输入区完成真实回答和引用点击。后续 UI 修正后的聚焦测试、全量回归、类型
检查、lint、工作区及暂存区 diff 检查均已通过;下表区分早期结果与最终结果。
| 需求范围 | 当前代码落点与行为 |
| --- | --- |
| `EK-FR-01` 至 `EK-FR-05` | `ExternalKnowledgeService`、`ExternalKnowledgeStore`、数据库 v12 迁移和类型化 IPC 已接入;支持加密保存实例、启停、目录分页、详情和手工 ID 检索验证 |
| `EK-FR-06` 至 `EK-FR-10` | `ExternalKnowledge.tsx` 与 `KnowledgeWorkspace.tsx` 已提供四种创建类型、两栏绑定表单、Provider 配置、统一列表和外部详情;参数只保存在本地绑定 |
| `EK-FR-11` 至 `EK-FR-14` | `KnowledgeService.retrieve` 按绑定分派;`retrieveMany` 已供聊天预检索和 MCP 搜索使用;外部定位、逐库失败和历史引用已接入共享契约与会话持久化 |
| `EK-FR-15`、`EK-FR-16` | 实例引用保护、本地移除确认、固定只读端点和有界请求已实现;传输与交互的实际限制见技术、UI 设计,不能视为原草案全部验收项已满足 |
实施代理与 owner 回报的测试记录如下,本轮文档核对没有重新执行这些测试:
| 分组 | 回报结果 | 证据边界 |
| --- | --- | --- |
| Client | 35 项测试通过 | 包含 FastGPT 响应包装、数组评分及取消等客户端契约修正 |
| Service 组 | 96 项测试通过 | 服务及关联集成分组结果,不单独推断每个文件的测试数量 |
| UI 组 | 107 项测试通过 | 组件及关联界面分组结果,不能替代 Electron 桌面操作验收 |
| 后续集成组 | 11 个 suite、403 项通过 | 后于上述分组;不与早期计数相加 |
| 中间全量测试 | 4,021 项通过、4 项失败、66 项跳过 | 历史结果;原失败已修正,最终回归通过 |
| 中间 App + Citation | 216 项通过、2 项失败 | 历史结果;原生控件测试交互已修正 |
| UI 复验前完整 App 测试 | 215 项通过 | owner 回报,覆盖整个 App 测试文件 |
| UI 复验前全量回归 | 347 个文件通过、9 个文件跳过;4,039 项通过、66 项跳过 | `npm test -- --maxWorkers=1 --reporter=verbose`;退出码 0,Vitest 耗时 677.40 秒 |
| UI 复验前类型检查 | 通过 | 当时最后代码修改后执行 `npm run typecheck`,owner 回报 |
| UI 复验前 lint | 通过 | 当时最后代码修改后执行 `npm run lint`,owner 回报 |
| UI 复验前 diff 检查 | 通过 | 当时最后代码修改后检查通过,owner 回报 |
| 最终聚焦测试 | 245 项通过 | 完整 App 与两个 Agent 测试文件,owner 回报 |
| 当前 UI 修正后最终全量回归 | 349 个文件通过、9 个文件跳过;4,081 项通过、66 项跳过 | `npm test -- --maxWorkers=1 --reporter=verbose`;退出码 0,Vitest 耗时 598.33 秒 |
| 最终类型检查与 lint | 通过 | `npm run typecheck`、`npm run lint`,owner 回报 |
| 最终工作区与暂存区 diff 检查 | 通过 | owner 回报;本轮另检查文档 diff |
各次测试覆盖有重叠,不合并为去重后的全仓总数。后续源码已确认 `retrieveMany` 按 rank
跨库轮转分配预算;App 引用事件使用共享 `knowledgeReferenceKey`,保留远端 ID 不同的
引用并去除重复项;引用弹窗的定位只显示一次。
UI 复验前全量证据来自临时目录中的 `goodbuddy-native-controls-fix-full.log` 和
`goodbuddy-native-controls-fix-full.result.json`,日志汇总与结果文件退出码已核对。
owner 确认 `App.tsx`、`App.test.tsx` 的哈希在整轮运行期间保持不变,最后代码修改后的
类型检查、lint 和 diff 检查均通过。之后用户要求重新检查 UI 并执行真实模型与知识库验证,
外部界面又有修改,因此该通过记录不自动覆盖当前源码。
原失败涉及 Preload 测试顺序沿用旧计数、原生选项控件的延迟焦点交互,以及并发导航;
相应修正已完成,并发导航由协作代理处理。引用去重与重复定位修正也已纳入最终回归。
精确自动测试计数仅在本文维护,其他设计文档和脱敏摘要引用本记录。
当前最终全量证据为 `goodbuddy-binding-selector-fix-full.log` 和
`goodbuddy-binding-selector-fix-full.result.json`,日志汇总和退出码已核对。最后一项 App
测试修正把旧原生 Select 操作改为实际富信息菜单的可访问交互,没有修改产品源码。
其他 Agent 测试失败由并发 owner 在其最新代码中解决,本轮未修改这些文件。
owner 确认 App 测试、两个 Agent 测试和 `ExternalKnowledge` 在整轮执行期间保持稳定,
不再追加源码修改。当前通过结果已覆盖后续 UI 版本,早期结果继续作为历史记录保留。
### 2026-09-13 生产服务与桌面验证
已读取脱敏的 `kb-strict-validation/report.json` 和
`kb-desktop-ui-validation-20260913/report.json`,并在
[产品验证摘要](./external-knowledge-validation-2026-09-13.json)归档必要字段。报告检查
当前生产实现,与下述 2026-09-09、2026-09-10 历史探测分开计证。
严格验证在真实 Electron 43.2.0 中运行生产 `KnowledgeService`、客户端和本机 HTTP MCP。
四个配置均完成绑定保存、凭据持久化后重开、响应 Schema 和引用映射检查;每个配置执行
绑定、服务检索、HTTP MCP 三次检索,共 12 次请求与 12 次响应。
| 配置编号 | 传输 | 目录 / 详情 | 服务结果 / MCP 引用 | 结论范围 |
| --- | --- | --- | --- | --- |
| Dify 1 | HTTPS,严格证书校验 | 成功 / HTTP 405 | 0 / 0 | 详情不兼容仍可绑定与检索,零命中是成功;没有非空证据 |
| Dify 2 | HTTPS,严格证书校验 | 成功 / 成功 | 2 / 2 | 非空服务检索与本机 HTTP MCP 成功 |
| FastGPT | HTTPS,严格证书校验 | 成功 / 成功 | 2 / 2 | 非空服务检索与本机 HTTP MCP 成功,未覆盖桌面 Provider 专属操作 |
| RAGFlow | HTTP,不涉及 TLS | 成功 / 成功 | 1 / 1 | 非空服务检索与本机 HTTP MCP 成功,未覆盖桌面图谱等专属操作 |
这些编号来自本轮报告,不由历史版本号推断。`safeStorage` 可用且真实加解密往返成功,
四个配置保存的凭据均在重开后可用。HTTPS 结论只覆盖表中三个 HTTPS 配置。
Dify 桌面验证使用当前 Main、Preload、Renderer、真实 BrowserWindow 和生产 IPC。
实例保存时,验证程序仅在 Main 的保存 Handler 边界替入已有测试密钥。已验证目录选择、
真实检索后绑定、本地参数保存和隔离桌面配置重启;保存参数为返回 2 条、超时 15 秒、
单片段 200 字符。14 次只读请求均为 HTTP 200,其中目录 4 次、详情 4 次、检索 6 次。
窗口按 1440、900、700 宽度检查,报告实际 viewport 为 1439、899、699,均无 body 横向
溢出;宽屏参数两栏、窄屏单栏及窄屏列表可见。原生 ArrowRight 切换并激活检索页签。
桌面凭据记录采用 `electron-safe-storage`,解密匹配且明文未写入被检查的凭据记录;
BrowserWindow 保持 sandbox、contextIsolation 开启和 nodeIntegration 关闭。
生产 `knowledge.search` 返回两条真实引用,验证程序通过 `conversations.saveLocal`
保存带测试标记的会话后重载,再打开生产引用弹窗。已检查片段、Provider、快照说明和
弹窗内焦点,未出现本地上下文或打开来源按钮。这里验证的是历史引用保存与阅读,
没有生成模型答案,也未证明模型会在真实回答中使用或引用证据。隔离配置和构建已清理。
截至上述早期验证,已知检索请求 30 次,上界 42 次:未保留诊断的首轮为 0 至 12 次、此前已确认
12 次、严格验证 12 次、桌面 6 次。首轮准确数量无法恢复。模型生成调用为 0;Provider
侧 Embedding、重排及审计用量不据此记为零。该计数不合并 9 月 9 日、10 日的历史探测。
Linux 测试 Host 的 LAN 与 VPN TCP 均可达,但验证程序读取已有 SSH 凭据时发生
`E_SAFE_STORAGE_DECRYPT_FAILED`,在认证 SSH 之前阻断。因此真实远程 forced-preflight
未执行;本机 HTTP MCP 成功不能替代远程生产路径验收。
### 2026-09-13 UI 修正与真实生成复验
用户指出原界面不符合要求,并要求真实模型与知识库一起验证。当前 `ExternalKnowledge.tsx`
与 CSS 已修正 Portal 移出工作区后丢失字段作用域的问题,改用共享 `.field`;实例弹窗收为
560px,密码使用眼睛图标,新建直接填凭据、编辑才选择保留或替换或清除。开关、粘附底部
操作区、带地址和凭据状态的实例菜单、EmptyState 与键盘行为均已更新。实现细节见
[UI 设计](./external-knowledge-ui-design.md#当前实现与验收边界)。
本轮读取 `kb-real-generation/report.json`、`kb-full-app-live/report.json` 和
`external-visual-0913/results.json`,脱敏摘要归档在
[UI 与真实生成复验](./external-knowledge-follow-up-2026-09-13.json)。摘要不保存模型端点、
私有配置标识、密钥、业务正文、生成答案或内容哈希。
| 验证层 | 实际覆盖 | 结果与边界 |
| --- | --- | --- |
| 生产 IPC 与真实模型 | 真实 Electron、Preload、已注册 IPC 和 ModelAgentRuntime;Dify/FastGPT/RAGFlow 的 `always`,以及 Dify `auto` | 最终场景均有非空短答案、依据匹配、引用标记、单库范围与文本/外部引用持久化证据;Renderer 是隔离验证 DOM,不是完整 App |
| 完整 App | 真实 Main、Preload、Renderer;从界面创建 Dify 实例及非空绑定,在输入区选择知识库并点击发送,Ask + `always` | 真实模型生成并保存答案,检索片段进入模型请求,点击引用后片段和远端文档/片段 ID 匹配,焦点在弹窗内;无伪造会话、引用或 Agent 事件 |
| 视觉与键盘 fixture | 当前组件和样式、真实 body Portal 与 Electron 原生键盘;IPC 为本地假数据 | 56 张截图、138 项检查通过,errors 为空;覆盖浅深主题及 1280×800、960×720、720×640、640×420,不作为真实 Provider 业务调用证据 |
生产 IPC 首轮 FastGPT/RAGFlow 已有非空、依据匹配和引用,但验证程序直接在序列化请求
字符串内寻找原片段,导致 `retrievedSnippetInBody=false`、`passed=false`。改为解码 Provider
请求及其中的证据 JSON 后,两家重跑均通过。归档保留首轮失败和修正后结果,不把首轮
改写为通过,也不把这次断言修正记成产品检索修复。Dify `auto` 通过进程内
`ModelToolProvider → KnowledgeMcpGateway` 执行 `knowledge_search`,不是 HTTP MCP 会话。
模型使用用户已保存的默认配置,凭据在 Main 用 safeStorage 解密。完整 App 检查确认原
模型设置未改变;Dify/FastGPT 请求使用严格 HTTPS,RAGFlow 和所用模型保留已配置的 HTTP。
完整 App 中的测试知识库密钥仅在 Main 保存 IPC 边界替入,报告没有公开实际密钥。
完整 App 另检查浅深主题的 Portal 编辑器:宽度 560px、字体与字号继承共享控件、背景
随主题变化,窗口内无横向溢出。视觉 fixture 检查了密码显隐前后布局、原生 Tab/Space、
开关、底部保存按钮、焦点循环与恢复、菜单跳过禁用项、页签、凭据替换和清除确认。
| 本轮请求 | 模型 | KB 全部 | KB 检索 | 目录 | 详情 |
| --- | --- | --- | --- | --- | --- |
| 生产 IPC 生成报告 | 7 | 12 | 9 | 3 | 0 |
| 完整 App 报告 | 1 | 5 | 3 | 1 | 1 |
| 合计 | 8 | 17 | 12 | 4 | 1 |
计数是本轮应用实际发出的 HTTP 请求,包含绑定验证和断言修正后的重跑;不推断上游代理
内部请求或 Provider Embedding、重排用量。生产 IPC 的检索含绑定验证 3 次、强制预检索
5 次、按需检索 1 次;完整 App 含创建验证 2 次、输入区发送触发 1 次。本轮采用精确增量,
不沿用生成报告中漏计早期桌面请求的累计字段。
“生成未经验证”已在上述短答案范围内解决。检查证明选中知识库的真实片段进入模型并
产生带依据和引用的短答案,不证明长文、多库冲突、复杂推理或广泛问答质量。完整 App
仍只覆盖 Dify;FastGPT/RAGFlow 完整桌面与专属参数、Linux 远程路径继续待验收。
当前 UI 修改后的聚焦测试、全量回归及相关检查已通过,精确结果见前文自动测试记录;
早期通过记录保持原时间范围。
### 历史验证记录
2026-09-10 官方 API 核对与真实实例复测: 2026-09-10 官方 API 核对与真实实例复测:
- 官方资料链接与版本差异见[技术设计第 9 节](./external-knowledge-technical-design.md#9-provider-映射)。 - 官方资料链接与版本差异见[技术设计第 9 节](./external-knowledge-technical-design.md#9-provider-映射)。
@@ -37,9 +188,12 @@
- 修正探测脚本将 Dify/RAGFlow 失败记成零命中的问题,增加实际调用与成功标记,统一 - 修正探测脚本将 Dify/RAGFlow 失败记成零命中的问题,增加实际调用与成功标记,统一
FastGPT 地址规范化并记录分数结构、响应中的重排状态。未修改产品客户端;旧 FastGPT FastGPT 地址规范化并记录分数结构、响应中的重排状态。未修改产品客户端;旧 FastGPT
检索数组包装、数组评分映射及上一轮发现的取消分类问题仍待处理。 检索数组包装、数组评分映射及上一轮发现的取消分类问题在当时仍待处理;当前客户端已修正,
当前服务和本机 HTTP MCP 的真实 Provider 复验见 2026-09-13 记录,旧包装等特定响应变体
仍不能仅凭成功计数认定覆盖。
- 三组探测测试共 43 项通过,包含两份新报告的脱敏与只读操作检查;`npm run typecheck`、`npm run lint` 通过。 - 三组探测测试共 43 项通过,包含两份新报告的脱敏与只读操作检查;`npm run typecheck`、`npm run lint` 通过。
`npm test` 先后达到 60 秒和 240 秒执行时限,均未输出汇总,全量回归尚未完成。 `npm test` 在该历史轮次先后达到 60 秒和 240 秒执行时限,均未输出汇总;当前最终全量
已通过,见 2026-09-13 记录。
- 本轮未验证真实 401/403/404/429、超时、取消、Scoped Key、分页、元数据过滤或候选数 - 本轮未验证真实 401/403/404/429、超时、取消、Scoped Key、分页、元数据过滤或候选数
参数效果。探测脚本不打包到桌面或 GoodBuddy Agent,本次未改变远程 Runtime 生产路径。 参数效果。探测脚本不打包到桌面或 GoodBuddy Agent,本次未改变远程 Runtime 生产路径。
@@ -92,8 +246,17 @@
## 剩余工作 ## 剩余工作
- 外部 Dify、FastGPT 和 RAGFlow 已完成 Main 适配基础;实例凭据持久化、绑定迁移、IPC、 - 完成 FastGPT/RAGFlow 桌面专属配置及实际参数效果检查;补充混合库、真实聊天
Renderer 管理界面、`retrieveMany`、聊天预检索和 MCP 网关仍未接入,当前不能由用户使用。 `always`/`auto` 的多库与复杂问答、停用和本地删除、未覆盖的主题及键盘路径。三家生产 IPC
短答案、Dify 完整输入区真实回答与引用,以及已检查的主题/尺寸/键盘不再列为首次待验收。
- 解决验证程序对已有 SSH 凭据的解密问题后,执行真实 Linux forced-preflight;网络已可达,
当前阻断点是认证前凭据读取。
- 三家服务已完成真实检索与本机 HTTP MCP。FastGPT/RAGFlow 部署版本、Dify 1 非空结果、
Scoped Key 和真实错误矩阵等仍需补证;旧探测不证明当前桌面配置效果。
- 原设计中的分项探测、版本能力展示、地址确认、请求取消交互和详细诊断与当前实现有差异,
具体范围见[技术设计](./external-knowledge-technical-design.md)和
[UI 设计](./external-knowledge-ui-design.md#当前实现与验收边界)。这些差异需由功能 owner 核对验收,
本轮仅改文档,不补产品代码或扩大架构。
- 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖 - 扫描 PDF 的真实 OCR 验收依赖用户已安装并校验的 OCR 模型;本次 DOCX/PDF 回归覆盖
原生文本 PDF,不宣称覆盖扫描件 OCR。 原生文本 PDF,不宣称覆盖扫描件 OCR。
- PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受 - PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受
+7 -3
View File
@@ -8,6 +8,10 @@
## 2. 信息架构 ## 2. 信息架构
当前列表同时容纳本地库与外部绑定,页面增加“管理外部实例”和来源筛选。以下详情结构、
资料导入和索引状态适用于本地库;外部创建、配置、检索测试及引用的当前实现与待验收项见
[外部知识库 UI 设计](./external-knowledge-ui-design.md)。
```text ```text
知识库 知识库
├─ 知识库列表 ├─ 知识库列表
@@ -23,9 +27,9 @@
## 3. 首次使用 ## 3. 首次使用
没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和 没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认
描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户 展示名称和描述,存储方式与知识图谱放入折叠的“高级设置”。本地库创建完成后进入
可以导入文件、文件夹或 URL。 “文档与来源”,用户可以导入文件、文件夹或 URL。
文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前 文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前
Conversation 的检索范围,返回对话并聚焦输入框。 Conversation 的检索范围,返回对话并聚焦输入框。
+22 -1
View File
@@ -263,8 +263,24 @@ Execute 直接启动已签名 Runtime:
- 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。 - 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。
固定 OpenCode 1.18.29 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录 固定 OpenCode 1.18.29 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录
写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent` 写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent`
转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理, 转发到所属 Task;不修改签名 Runtime 包。插件文件随 helper 退出清理,
事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。 事件继续经过既有 Agent transcript、ACK 和 Desktop 消息持久化路径。
- 托管 helper 显式设置 `OPENCODE_ENABLE_QUESTION_TOOL=true`,启用 ACP 默认隐藏的原生
问题工具。插件只处理 `question.asked`,通过 Session `parentID` 逐级确认根会话,并核对
`/question` 当前待答列表;不从工具进度或正文构造问题。父、子孙会话问题使用
`_meta.goodbuddyQuestion` 送到所属 ACP 会话,有原生 Task 关联时附带对应 call ID。
- Agent 按当前 ACP 会话和 Prompt 保存待答映射,Desktop 公开 ID 包含 binding、operation
和原生问题 ID。回答与跳过经既有认证控制通道的 `runtime/respondToQuestion` 发送;后端
检查 controller、generation、connection、binding 和活动 operation,再调用插件仅绑定
`127.0.0.1` 的临时 HTTP 入口。该入口使用进程级随机路径,能力 URL 不写入语义记录。
插件复用 OpenCode 注入客户端的进程内 HTTP 适配器调用 `/question/{id}/reply` 或
`/reject`;空答案数组表示拒答,非空答案数量须与问题数量一致。问答不经过 ACP 权限审批。
- 语义记录分页另带 Agent 当前 `pendingQuestions`,用于附加同一存活 Prompt 后恢复待答
问题;历史问答元数据不重新生成表单。原生已回答、拒答、会话空闲、取消、Prompt 终结
和进程退出清除相应映射,迟到的回答报错。回复失败保留桌面输入供重试,答案不会跨连接
自动重放。插件随 OpenCode 实例释放临时端口,helper 退出时删除插件文件。
以上只适用于当前 Agent 管理的 SSH OpenCode 生产路径,不声明任意 ACP 服务的问答兼容性。
真实二进制、SSH 传输和取消验证见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-托管-ssh-原生问答)。
- ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的 - ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的
`<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属 `<task><task_result>` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属
`toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。 `toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。
@@ -321,6 +337,11 @@ Execute 直接启动已签名 Runtime:
权威。启动后 Renderer 先订阅恢复进度,再触发每项目恢复。项目选择器依次显示网络、 权威。启动后 Renderer 先订阅恢复进度,再触发每项目恢复。项目选择器依次显示网络、
Agent、Runtime、cursor、完成或失败/重试;只阻塞受影响项目的发送和队列,已有历史与 Agent、Runtime、cursor、完成或失败/重试;只阻塞受影响项目的发送和队列,已有历史与
其他项目保持可用。 其他项目保持可用。
- 完成提示是项目选择器内恢复进度的收尾反馈,首次收到该项目该 `requestId` 的完成状态后
显示 3 秒,然后从收起按钮和展开菜单同时隐藏;不另发全局成功通知。计时由
`ProjectSwitcher` 持有,切换项目、开合菜单或重复状态快照不延长期限,也不重放已隐藏的
提示;新 `requestId` 的恢复可以再次展示。进行中状态和失败/就地重试持续保留。
隐藏仅作用于展示层,`App` 保留完成业务状态,继续用于发送、队列恢复和事件顺序判断。
- 正常应用退出对已接受托管 SSH 请求执行 detach,不等待远端完成;本地请求和尚未接受的 - 正常应用退出对已接受托管 SSH 请求执行 detach,不等待远端完成;本地请求和尚未接受的
请求仍按原行为取消。用户显式“停止”始终发送稳定 operation cancellation,并等待 Agent 请求仍按原行为取消。用户显式“停止”始终发送稳定 operation cancellation,并等待 Agent
语义终态。已进入 Agent-owned `run` 但 start/attach 响应仍不确定的请求继续保留为 语义终态。已进入 Agent-owned `run` 但 start/attach 响应仍不确定的请求继续保留为
+12
View File
@@ -0,0 +1,12 @@
# 质量验证
本目录收录跨功能测试方法。功能验收结果仍记录在对应功能的 `progress.md`,不在此复制通过记录。
| 文档 | 职责 |
| --- | --- |
| [Electron UI 自动化测试规范](./electron-ui-automation.md) | 测试分层、当前源码启动、视觉与原生交互取证、真实调用计数、脱敏报告和清理 |
| [开发与构建](../../BUILD.md#质量验证) | 依赖、测试、类型检查、Lint 和构建命令 |
| [UI 设计系统](../../UI-DESIGN.md#155-验收) | 全局视觉、交互和响应式验收要求 |
| [知识检索评估](../features/knowledge-base/retrieval-evaluation.md) | 检索数据集、指标及评估方法 |
| [真实 Runtime 测试](../../BUILD.md#质量验证) | 真实模型 Runtime E2E 的现有入口及范围 |
| [发布操作](../development/release-runbook.md) | 候选 CI、发布构建及发布验证;开发期 UI 测试不替代发布检查 |
+201
View File
@@ -0,0 +1,201 @@
# Electron UI 自动化测试规范
本文规定开发期 Electron UI 自动化的执行和取证方法,适用于表单、弹窗、菜单、主题、响应式布局及其业务路径。视觉与交互预期以 [UI 设计系统](../../UI-DESIGN.md)和功能设计为准;通用命令见[开发与构建](../../BUILD.md#质量验证),功能实测结果写入各功能的进度文档。
## 1. 测试分层与选择
先写清本次改动影响什么、在哪一层验证,以及该层替换了哪些依赖。按影响选择场景,fixture 和真实服务测试提供互补证据。
| 层次 | 运行方式 | 能证明的范围 | 不能据此宣称 |
| --- | --- | --- | --- |
| 单元与组件回归 | Vitest、Testing Library、契约及 CSS 回归 | 状态分支、语义和回归断言 | Electron 实际排版、原生键盘默认行为或生产端到端成功 |
| Electron 视觉 fixture | 当前组件、入口样式、i18n、主题函数;真实 Chromium 与 body Portal;IPC 返回受控假数据 | 排版、主题、控件外观、焦点、原生交互,以及受控空状态和错误状态 | 持久化、真实检索、模型生成或完整 App 接线成功 |
| 生产 IPC 集成 | 当前 Main 服务、注册 IPC、真实 Preload、隔离数据;按需接真实服务 | 跨进程契约、生产存储、检索及生成路径 | 用隔离验证 DOM 展示事件时,不能称为完整 App 输入区或引用 UI 验收 |
| 完整 App | 当前生产 Main、Preload、Renderer,隔离 profile,从真实 UI 入口完成操作 | 用户动作到生产实现及最终可见结果 | 未执行的平台、Provider、模式、重启或远程路径 |
纯视觉修复通常先做组件回归和 Electron 视觉矩阵,在完整 App 中复核受影响入口及 Portal 接线;无需为每种主题、尺寸或 CSS 修改重复调用模型。保存、持久化、检索、生成、引用或 IPC 接线变化,补对应生产路径。真实生成声明必须来自真实生成请求;健康检查、模型列表、HTTP 200 或 fixture 答案均不足以证明。
沿用 [AGENTS.md 的验证授权](../../AGENTS.md#validation):验证变更路径所必需的有界真实文本模型调用已有持续授权,不逐次询问。只运行必要的最小请求,尽量关闭无关工具与附件;按需工具检索场景保留所需工具。本文不新增产品策略或审批流程。
## 2. 方法来源与证据边界
2026-09-13 的外部知识库复验是本规范的方法来源。永久记录由[知识库进度](../features/knowledge-base/progress.md)及[脱敏复验摘要](../features/knowledge-base/external-knowledge-follow-up-2026-09-13.json)维护。下表用于说明测试方法,不表示本文编写时重新执行了测试。
| 原始临时证据 | 已观察事实 | 保留的限制 |
| --- | --- | --- |
| `external-visual-0913/results.json`、`preview.tsx`、`electron.cjs`、`run.mjs` 及 PNG | 当前源码组件和 `styles.css`、真实 body Portal、fixture IPC;浅深主题,1280×800、960×720、720×640、640×420;56 张场景截图、138 项检查,记录中无失败断言和 errors;计算样式、溢出、焦点与底部操作区配合 PNG 审阅 | 额外拼图和残留 `failure.png` 不计入 56 张;各尺寸场景数不同,未覆盖的组合不能按笛卡尔积推定通过;未验证真实服务 |
| `kb-real-generation/report.json` | 真实 Electron、当前 Preload、已注册生产 IPC 和 ModelAgentRuntime;三个 Provider 的 `always`、Dify `auto`;文本和引用由生产存储保存 | Renderer 是隔离验证 DOM;`auto` 经进程内 `ModelToolProvider → KnowledgeMcpGateway`,不是 HTTP MCP 会话;首轮两家比较器误判保留,解码请求和内嵌证据 JSON 后重跑通过 |
| `kb-full-app-live/report.json` | 当前完整生产 App,隔离 safeStorage profile;UI 创建实例与非空绑定,在实际输入区选择 Dify 并点击发送,真实短答案生成、保存并点击引用;片段及文档/分块匹配 | 仅 Dify、Ask、`always`;测试密钥在 Main 保存 IPC 边界替入;无伪造消息、引用或 Agent 事件;不证明广泛问答质量或其他 Provider 的完整 App |
早期验收曾仅凭“没有横向溢出”漏掉 Portal 移到工作区外后字段样式失效的问题。修正后复验同时检查 `.field` 共享样式、字体、背景、密码显隐布局和实际 PNG。以后不得把 `scrollWidth <= clientWidth` 当作视觉通过的充分条件。
上述生产 IPC 报告实际发出模型请求 7 次、KB 请求 12 次,其中检索 9 次、目录 3 次;包含比较器修正后的两次生成重跑,Runtime 模型重试为 0。完整 App 另发出模型请求 1 次、KB 请求 5 次,其中检索 3 次、目录和详情各 1 次。本轮精确增量为模型 8 次、KB 17 次;不采用原报告中覆盖不完整的历史会话累计区间。后续报告应独立计数,不沿用这些数字作为预期或配额。
原始文件位于当时工作机批准的临时目录。当前可读取视觉脚本;完整 App 的隔离 profile 和构建已删除,其目录只保留报告和截图。它们不是仓库承诺维护的 runner,也不是新环境的依赖。没有原始文件时仍可按下节重建临时驱动,但必须记录“驱动重建”,不能声称复用了原脚本或复现了全部历史证据。
## 3. 当前源码与隔离启动
### 环境与命令
需要 [BUILD.md](../../BUILD.md#环境要求)要求的 Node.js、锁定 npm 依赖、能显示窗口的 Electron 桌面会话,以及本次专用临时目录。记录操作系统、架构、Electron 版本、语言、主题、缩放和内容区尺寸。真实凭据场景还需要当前 OS 用户可用的 `safeStorage` 及已授权配置。
在仓库根目录逐条执行以下准备命令;依赖已经满足时不重复安装:
```powershell
node --version
npm ci
git rev-parse HEAD
git status --short
node -p "require('electron/package.json').version"
```
用 HEAD 和相关未提交变更摘要标识本次源码。读取当前 [Electron Vite 配置](../../electron.vite.config.ts)和 [Main 入口](../../src/main/index.ts),确认 Renderer、Preload、动态加载资源及用户数据路径。
开发期需要完整生产产物时执行 `npm run build`,确认成功后再用隔离启动器加载 `out/main/index.js`,并记录 Main、Preload、Renderer 入口及相关资源的构建标识。不要把已安装的旧版或陈旧 `out` 当作当前源码。并行任务会修改 `out` 时,使用本次专属源码/构建副本,或在测试前后核对产物哈希;不要覆盖别人的构建。[发布操作](../development/release-runbook.md)另有候选 CI 构建规则,本节不改变该流程。
### 临时驱动的复现步骤
仓库已有 [Electron 启动脚本](../../build/run-browser-tabs-electron-e2e.cjs)及[对应测试](../../tests/browser-tabs-electron-e2e.ts),可复用其 `esbuild` 临时 bundle、`external: ['electron']`、解析仓库 Electron 可执行文件、清除 `ELECTRON_RUN_AS_NODE` 和退出码处理方式。`node build/run-browser-tabs-electron-e2e.cjs` 是现有浏览器服务测试命令,不是通用 UI 矩阵命令,也不覆盖完整 App。
1. 在本次临时目录准备场景入口、Electron bootstrap 和独立 Node supervisor。依赖从当前仓库解析;临时目录外脚本可用 `createRequire` 指向仓库 `package.json`,不要假定临时目录能自动解析 `react` 或 `electron`。
2. 视觉层用 Vite `createServer` 服务临时入口,引用当前组件、`styles.css`、i18n 和 `applyAppearanceTheme`;React 依赖指向同一仓库实例,服务仅监听 loopback 随机端口,文件允许范围限于本次目录及仓库。保留真实 Portal 容器和位置,仅替换约定的 IPC 返回值;不要另画一份“相似 UI”。
3. Electron bootstrap 在 `app.whenReady()` 前设置本次 `userData`、`sessionData` 和日志目录。视觉层创建 `BrowserWindow`,采用 `useContentSize: true`,通过 `setContentSize` 切换矩阵;完整 App 则在设置隔离路径后动态导入当前生产 Main,由生产代码创建窗口、注册 IPC 和加载实际 Preload/Renderer。监听 `browser-window-created` 找到主窗口,确认最终数据路径仍在测试目录,避免便携路径规则覆盖隔离设置。
4. supervisor 用 Node `spawn` 启动 Electron 子进程,在子进程环境副本中删除 `ELECTRON_RUN_AS_NODE`。否则 Electron 可能作为 Node 执行,窗口和 `app` API 不可用。完整 App 检查实际加载的入口;不要绕过主程序仅挂载 `App` 后称为完整生产路径。
5. 等待页面就绪、目标可见、`document.fonts.ready` 和所需异步状态;所有等待都有阶段期限。通过 `webContents.executeJavaScript` 读取 DOM、测量布局和定位元素,通过 Electron 输入 API 驱动交互,通过 `capturePage().toPNG()` 保存截图。记录程序化定位焦点的步骤,完整 Tab 导航另行验证。脚本按当前控件的可访问角色操作,例如实例选择器改为菜单后,应同步更新旧版 `select` 驱动。
6. 场景完成即写检查和证据索引;失败也写阶段、退出码/信号、脱敏诊断与截图。结束后由 supervisor 核对结果完整性并清理本次子进程、服务和 profile。
生产窗口保持 `contextIsolation: true`、`sandbox: true`、`nodeIntegration: false` 及既有 CSP、可信发送方校验和显式 Preload API。不要复制其他专用 smoke 中的 `--no-sandbox`,也不要为驱动增加 `eval`、修改生产安全标志或向 Renderer 暴露 Node。驱动本身无法启动应记录为 harness 故障。
原视觉目录仍存在时,可先在 PowerShell 校验文件可用性。以下只检查可用性,不启动测试:
```powershell
$visualRun = Join-Path $env:TEMP 'opencode\external-visual-0913'
Test-Path -LiteralPath $visualRun
'run.mjs', 'electron.cjs', 'preview.tsx', 'index.html', 'results.json' | ForEach-Object {
[pscustomobject]@{ File = $_; Exists = Test-Path -LiteralPath (Join-Path $visualRun $_) }
}
```
原脚本包含工作机绝对路径和固定输出文件名。先读脚本、复制到新的临时运行目录并适配源码路径、输出目录、依赖解析及当前选择器,避免覆盖历史失败诊断;随后才可 `node "<本次目录>/run.mjs"`。这是需要适配的命令模板,仓库没有 `npm run test:ui` 或一条命令重放上述全部报告的入口。
### 超时与进程监督
监督进程独立于 Electron 和测试执行进程,持有明确的子进程句柄、开始时间和总期限。一次工具调用的 60 秒等待窗口不等于整个测试允许时长;长任务后台执行并定期读取状态,工具等待结束后仍能取得进度和最终结果。
历史视觉脚本的 watchdog 为 115 秒,现有浏览器服务脚本为 120 秒,都只适用于各自范围。2026-09-13 的全量 `npm test -- --maxWorkers=1 --reporter=verbose` 实测 599,334 毫秒,约 10 分钟。新一轮应按机器和规模给出有余量的有限预算,例如 15 分钟起,并记录预算和实际耗时,不能照搬 60 秒总上限或无限等待。
区分单步等待、服务请求超时和整轮超时。定期保存进度,超时先保留最后阶段、输出尾部及可取得的截图,再终止本次进程树。普通 `child.kill()` 未必能回收孙进程;按 PID 和启动归属核实,只清理本次 Electron、Node、临时服务及监听,不按进程名批量结束正在使用的 GoodBuddy。信号退出、超时或报告缺失均不得记为通过。
## 4. 视觉与原生交互取证
受影响组件默认检查浅色和深色主题,以及 `1280×800`、`960×720`、`720×640`、`640×420` 内容区。它们对应设计系统的宽度区间及短窗口压力场景;产品另有断点、长文案或缩放要求时补充场景。记录实际 `innerWidth/innerHeight` 和设备缩放,不能用带窗口边框的尺寸替代。
每个场景同时保存动作结果、计算值和 PNG。检查页面、弹窗、菜单、字段及操作区的 `getBoundingClientRect()`,并对文档及局部滚动容器比较 `scrollWidth/clientWidth`、`scrollHeight/clientHeight`。正常纵向滚动不算失败,但裁切、无意横向溢出、按钮不可达或滚动内容被粘附区遮挡需要单独断言。
Portal 必须真的挂在生产位置。检查它是否在工作区外、主题变量是否仍可用、背景是否随主题切换;读取字段 `fontFamily`、`fontSize`、高度、边框、圆角、背景和文字色。共享 `.field`、字体令牌、PageTabs、SegmentedControl、Switch、Checkbox、EmptyState 和通知模式按 [UI 设计系统](../../UI-DESIGN.md)核对,不在本规范重新定义视觉数值。
密码显隐前后比较尺寸、字体、背景及相邻按钮位置;敏感输入只能用明确标识的 fixture 文本截图。选中态在 hover 时保持可辨,禁用项不会被键盘选中。底部保存区除了位于窗口内,还要验证聚焦的字段没有落到页头或粘附底部下面、末项可滚到并操作、焦点轮廓可见。
键盘采用 `webContents.sendInputEvent` 的 `keyDown/keyUp`,文本可用 `insertText`。窗口和 webContents 先获得焦点;必要时为 Enter 发送 `char`。按控件实际语义覆盖 Tab、Shift+Tab、Space、Enter、Escape、方向键、Home/End,并验证 `document.activeElement`、选中值和弹窗状态。`dispatchEvent(new KeyboardEvent(...))` 不触发同等 Chromium 默认行为,不能用来证明原生 Tab 顺序、Space 开关或 select 行为。
检查焦点进入弹窗、正反向循环、背景 `inert`、关闭后恢复触发按钮;菜单跳过禁用项、选择后回到触发器;页签的选中状态与面板一致。表单取消保留原值,错误关联正确,失败保留输入和可操作上下文;成功通知不重复显示。
PNG 必须实际打开审阅,并写明审阅方式、具体文件及发现。拼图用于找异常,小尺寸、焦点、字段和异常截图还要按原尺寸检查;确认字体、对比度、密度、层级、遮挡和控件是否像共享设计。截图文件存在或自动像素相同都不能替代审阅。只读了 JSON 时应写“PNG 未审阅”。
## 5. 保存、真实生成与故障
涉及保存时,从实际 UI 修改、提交、重新打开,核对生产读取值;涉及重启持久化时关闭本次 App,用同一隔离 profile 重启,再验证状态。fixture 返回保存成功只证明前端反馈;数据库有记录也不等于已经完成重启验证。空列表、无搜索结果、加载、失败、取消和重试按改动范围检查,明确故障是 fixture 注入、传输注入还是真实服务返回。
完整 App 的真实生成场景从实际输入区选择范围、提交最短必要问题,等待检索、生成及完成事件,核对持久化答案和引用;点击生成答案中的引用,检查弹窗片段、文档/分块定位与生产保存的引用匹配。需要证明依据进入模型时,在 Main 本地解码实际 Provider 请求和内嵌证据后比较,只归档布尔结果与计数。不要用原始序列化字符串匹配造成转义误判。
不得注入答案、引用、消息或 Agent 事件后宣称真实生成/引用验收通过。生产 IPC 测试可用最小验证 DOM 观察真实事件,但要如实标注 Renderer 范围。真实片段进入模型、短答案依据匹配和引用正确,只证明该次路径;不能据此推断复杂问答质量。
### 配置与传输
凭据只在 Main 通过既有存储读取和解密。需要复用默认模型时,将所需加密配置复制到隔离 profile,核对原配置未改变;safeStorage 依赖 OS 用户及加密元数据,解密失败应保留诊断,不改为明文或修改正式设置。历史完整 App 驱动仅复制必要的加密 `os_crypt` 元数据,并在 Main 保存 IPC 边界替入测试 KB 密钥,这一替换必须在方法说明中披露。
HTTPS 验证在隔离子进程显式使用严格证书校验。检查实际 fetch/undici dispatcher、证书忽略开关和监听器;仅设 `NODE_TLS_REJECT_UNAUTHORIZED=1` 不足以证明请求没有被其他配置绕过。历史完整 App 在隔离子进程设置 `rejectUnauthorized: true` 并移除测试环境的证书绕过,不修改生产源码或全局配置。
端点沿用已授权配置,不在验证中擅自升级或替换协议。历史 Dify/FastGPT 使用严格 HTTPS,RAGFlow 和默认模型使用已配置的 HTTP。报告应分别记录各服务的 `http`/`https` 及 HTTPS 校验情况;`strictTls: true` 不能写成“全部请求 HTTPS”。
### 调用计数与失败诊断
从应用实际 HTTP dispatch 边界计数,按运行、场景、阶段和 attempt 记录。区分模型请求、KB 目录、详情、绑定验证检索、聊天预检索、按需工具检索;另记输入区提交数、工具执行数、Runtime 自动重试及人工/驱动重跑。一次提交可产生多个模型请求,不能用提交数代替用量。
计数包含失败、取消前已发出的请求和重跑;启动前失败仍记 attempt,但网络数为 0。已发出但未收到响应的请求也计入,并标明结果未知。上游代理内部重试、服务商 Embedding/重排不可观察时直接写不可观察。旧轮次缺少日志时报告已知精确增量与缺口,不能把估算区间当精确总数。
失败记录保留场景、动作、预期/实际、阶段、HTTP 状态或脱敏错误码、退出信号、截图和重跑关联。区分产品故障、harness 错误、环境阻塞与超时。修正比较器或选择器后重新取证,保留首轮失败,不将其改写为通过或误称为产品修复。控制台错误、`page` 加载失败及进程崩溃也应纳入报告。
## 6. 可复用检查清单
未执行项注明 `not-run`、原因和影响;不适用项注明 `not-applicable`。重启、错误或平台场景也须有本次执行证据后再勾选。
- [ ] 列出变更范围、所选测试层、fixture/真实边界、当前源码及构建标识。
- [ ] 确认桌面会话、依赖、临时目录、隔离 profile、safeStorage 前提及原设置不变。
- [ ] 从独立 supervisor 启动当前 Electron,清除 run-as-node 环境,设置阶段与整轮期限。
- [ ] 保持生产 Main/Preload/Renderer 路径与安全标志,披露测试替换点。
- [ ] 检查浅深主题、四组内容区尺寸,以及实际长文案和空/加载/失败状态。
- [ ] 真实 Portal 的共享字体、字段、主题和控件计算值正确;密码显隐无布局跳变。
- [ ] 检查文档和局部溢出、滚动、焦点轮廓、底部按钮及字段无遮挡。
- [ ] 原生键盘完成适用流程,菜单、页签、开关、焦点循环与恢复都有动作后断言。
- [ ] 打开 PNG 审阅,记录文件、方式、缺陷及修正后的复验。
- [ ] 按影响验证真实保存、重新打开、重启、取消、错误恢复;fixture 不冒充持久化。
- [ ] 需要真实生成时,从所选层的入口取得真实答案/引用证据,完整 App 场景实际点击引用。
- [ ] 精确统计模型与 KB dispatch、失败和重跑,协议与 TLS 分开记录。
- [ ] 源码变更完成仓库要求的 `npm test`、`npm run typecheck`、`npm run lint`,保留完整结束状态;仅修改文档时做文档检查即可。
- [ ] 报告覆盖范围、未测项和失败诊断,核对测试前后源码/产物是否被并行修改。
- [ ] 脱敏后归档摘要,清理本次进程、监听、配置和临时敏感资料,记录清理结果。
## 7. 脱敏报告格式
下例是未执行模板,所有测量值为 `null`,没有伪造通过证据。`null` 表示未知或未测,只有确认没有调用时才填 `0`。状态使用 `not-run`、`passed`、`failed`、`blocked`、`not-applicable`;场景及断言逐项记录,不能只有总数。
```json
{
"schemaVersion": 1,
"kind": "template-not-executed",
"runId": null,
"source": { "commit": null, "dirtyScope": [], "artifactHashes": {} },
"environment": { "os": null, "arch": null, "electron": null, "locale": null, "scale": null },
"scope": { "layers": [], "fixtureIpc": null, "fullProductionApp": null, "substitutions": [], "limitations": [] },
"execution": { "command": null, "status": "not-run", "timeoutMs": null, "elapsedMs": null, "exitCode": null, "signal": null },
"scenarios": [
{
"id": "replace-with-scenario-id",
"layer": null,
"theme": null,
"viewport": null,
"attempt": null,
"rerunOf": null,
"actions": [],
"checks": [{ "name": "replace-with-check", "status": "not-run", "expected": null, "actual": null }],
"computed": {},
"screenshots": [],
"pngReview": { "status": "not-run", "method": null, "files": [], "findings": [] }
}
],
"requests": {
"boundary": "application HTTP dispatches for this run only",
"model": null,
"kbTotal": null,
"kbCatalog": null,
"kbDetail": null,
"kbRetrieval": null,
"runtimeModelRetries": null,
"composerSubmissions": null,
"toolExecutions": null,
"ledger": [],
"unknowns": []
},
"transport": [{ "serviceAlias": "service-1", "protocol": null, "strictCertificateValidation": null }],
"failures": [],
"cleanup": { "testProcessesStopped": null, "listenersClosed": null, "isolatedProfileRemoved": null, "sensitiveArtifactsRemoved": null, "originalSettingsUnchanged": null },
"redaction": { "status": "not-run", "omitted": ["credentials", "endpoints", "private identifiers", "business and answer prose", "content hashes"] }
}
```
`ledger` 每条记录使用匿名服务标识、场景 ID、attempt、阶段、请求种类、HTTP 方法、协议、状态/错误类别和是否重试;不含 URL、请求头或正文。`failures` 使用同一场景/attempt 关联诊断与重跑。计算值可保存字体、颜色、边界与溢出数,排除 `textContent` 等可能带出业务文本的字段。
截图、完整 DOM、请求体和本地数据库可能含敏感资料,只在本次临时目录用于核验。仓库归档不包含凭据、真实端点、私有配置/远端文档 ID、问题和答案正文、业务片段或这些内容的哈希。需要比较时在本地完成,归档布尔结果、长度或数量;源码/构建产物哈希可用于标识测试版本。可归档截图必须使用无敏感数据的 fixture 或完成脱敏审阅,路径使用相对证据名。
先保留可复查的脱敏失败诊断,再清理原始敏感资料;不要为清理而丢掉失败原因。结束时删除本次隔离配置、加密凭据副本、数据库及不需保留的临时构建,关闭测试进程和端口,核对正式设置未改。仍需本地排障的资料注明临时位置用途与待清理项,不把临时绝对路径当永久证据链接。功能进度链接最终脱敏摘要,并明确原始 harness/截图是否仍可取得。
@@ -10,6 +10,7 @@ import {
type SessionNotification type SessionNotification
} from '@agentclientprotocol/sdk' } from '@agentclientprotocol/sdk'
import { canonicalJson } from '../shared/agent-protocol/canonical' import { canonicalJson } from '../shared/agent-protocol/canonical'
import { remoteQuestionSchema, type RemoteQuestionResponse } from '../shared/remote-question-contracts'
import type { import type {
RemoteOwnedPromptStartRequest, RemoteOwnedPromptStartRequest,
RemoteOwnedPromptStartResult RemoteOwnedPromptStartResult
@@ -57,6 +58,10 @@ export class AgentOwnedAcpPrompt {
#promptPromise?: Promise<void> #promptPromise?: Promise<void>
#closed = false #closed = false
#initialized = false #initialized = false
readonly #questions = new Map<string, {
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
}>()
#acceptQuestions = false
#active?: { #active?: {
operationId: string operationId: string
} }
@@ -94,6 +99,8 @@ export class AgentOwnedAcpPrompt {
return return
} }
const error = new Error('ACP Runtime process exited') const error = new Error('ACP Runtime process exited')
this.#acceptQuestions = false
this.#questions.clear()
controller?.error(error) controller?.error(error)
controller = undefined controller = undefined
rejectProcessExit(error) rejectProcessExit(error)
@@ -213,6 +220,7 @@ export class AgentOwnedAcpPrompt {
this.#active = { this.#active = {
operationId: request.operationId operationId: request.operationId
} }
this.#acceptQuestions = true
const begun = this.#options.transcript.begin({ const begun = this.#options.transcript.begin({
bindingId: this.#options.bindingId, bindingId: this.#options.bindingId,
operationId: request.operationId, operationId: request.operationId,
@@ -277,6 +285,8 @@ export class AgentOwnedAcpPrompt {
} }
) )
.finally(() => { .finally(() => {
this.#questions.clear()
this.#acceptQuestions = false
this.#active = undefined this.#active = undefined
}) })
void this.#promptPromise.catch(() => undefined) void this.#promptPromise.catch(() => undefined)
@@ -284,6 +294,8 @@ export class AgentOwnedAcpPrompt {
} }
async cancel(): Promise<void> { async cancel(): Promise<void> {
this.#acceptQuestions = false
this.#questions.clear()
if (this.#sessionId !== undefined && !this.#closed) { if (this.#sessionId !== undefined && !this.#closed) {
await this.#connection.cancel({ sessionId: this.#sessionId }) await this.#connection.cancel({ sessionId: this.#sessionId })
} }
@@ -293,6 +305,29 @@ export class AgentOwnedAcpPrompt {
this.#clear() this.#clear()
} }
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
const pending = this.#questions.get(request.questionId)
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
pending.operationId !== request.operationId || request.bindingId !== this.#options.bindingId) {
throw new Error('Remote question is no longer pending for this prompt')
}
if (request.answers.length !== 0 && request.answers.length !== pending.questionCount) {
throw new Error('Question answer count does not match')
}
const response = await fetch(pending.endpoint, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...request, sessionId: this.#sessionId }),
signal: AbortSignal.timeout(30_000), redirect: 'error'
})
if (!response.ok) throw new Error(`OpenCode question response failed (${response.status})`)
this.#questions.delete(request.questionId)
}
pendingQuestions(operationId: string): SessionNotification[] {
if (!this.#acceptQuestions || this.#active?.operationId !== operationId) return []
return [...this.#questions.values()].map(question => question.notification)
}
#client(): Client { #client(): Client {
return { return {
requestPermission: async (request) => requestPermission: async (request) =>
@@ -306,12 +341,38 @@ export class AgentOwnedAcpPrompt {
} }
#handleSessionUpdate(notification: SessionNotification): void { #handleSessionUpdate(notification: SessionNotification): void {
if (this.#closed || !this.#active) return
if ( if (
notification.sessionId !== this.#sessionId && notification.sessionId !== this.#sessionId &&
this.#sessionId !== undefined this.#sessionId !== undefined
) { ) {
throw new Error('ACP session notification identity changed') throw new Error('ACP session notification identity changed')
} }
const resolved = notification.update._meta?.goodbuddyQuestionResolved
if (typeof resolved === 'string') {
this.#questions.delete(resolved)
return
}
const extension = notification.update._meta?.goodbuddyQuestion
if (extension !== undefined) {
if (!this.#acceptQuestions) return
if (!extension || typeof extension !== 'object') throw new Error('Invalid remote question')
const value = extension as { question?: unknown; endpoint?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
if (typeof value.endpoint !== 'string' ||
!/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(value.endpoint)) {
throw new Error('Invalid remote question endpoint')
}
if (this.#questions.has(question.id)) return
// The process-local reply capability never enters the durable transcript.
notification = { ...notification, update: {
...notification.update, _meta: { goodbuddyQuestion: {
question, ...(typeof value.childCallId === 'string' ? { childCallId: value.childCallId } : {})
} }
} }
this.#questions.set(question.id, { endpoint: value.endpoint, operationId: this.#active.operationId,
questionCount: question.questions.length, notification })
}
this.#options.transcript.append({ this.#options.transcript.append({
bindingId: this.#options.bindingId, bindingId: this.#options.bindingId,
operationId: this.#requireActive().operationId, operationId: this.#requireActive().operationId,
@@ -371,6 +432,8 @@ export class AgentOwnedAcpPrompt {
return return
} }
this.#closed = true this.#closed = true
this.#acceptQuestions = false
this.#questions.clear()
this.#unsubscribeOutput() this.#unsubscribeOutput()
this.#unsubscribeExit() this.#unsubscribeExit()
} }
+2 -1
View File
@@ -871,7 +871,8 @@ function credentialFreeHelperEnvironment(
'XDG_STATE_HOME' 'XDG_STATE_HOME'
] as const ] as const
const environment: NodeJS.ProcessEnv = { const environment: NodeJS.ProcessEnv = {
OPENCODE_CONFIG_CONTENT: config OPENCODE_CONFIG_CONTENT: config,
OPENCODE_ENABLE_QUESTION_TOOL: 'true'
} }
for (const name of allowed) { for (const name of allowed) {
const value = source[name] const value = source[name]
+1
View File
@@ -117,6 +117,7 @@ describe('model bridge loopback helper', () => {
expect(args).toEqual(['acp']) expect(args).toEqual(['acp'])
expect(options).toMatchObject({ shell: false, stdio: 'inherit' }) expect(options).toMatchObject({ shell: false, stdio: 'inherit' })
expect(options.env).toMatchObject({ expect(options.env).toMatchObject({
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
...resources, ...resources,
LANG: profile.env.LANG, LANG: profile.env.LANG,
LC_ALL: profile.env.LC_ALL, LC_ALL: profile.env.LC_ALL,
@@ -1,13 +1,74 @@
import { runInNewContext } from 'node:vm' import { runInNewContext } from 'node:vm'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin' import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
describe('OpenCode ACP child event plugin', () => { describe('OpenCode ACP child event plugin', () => {
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
const write = vi.fn()
const question = { id: 'q', sessionID: 'grandchild', questions: [
{ header: 'Input', question: 'Choose', options: [] }
] }
let listed: unknown[] = [question]
const get = vi.fn(async () => ({ data: listed }))
const post = vi.fn(async () => ({}))
const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
{ process: { stdout: { write } }, createServer, randomBytes }
)
const hooks = await factory({ client: {
_client: { get, post }, session: { get: async ({ path }: { path: { id: string } }) => ({
data: { id: path.id, parentID: path.id === 'grandchild' ? 'child' : path.id === 'child' ? 'parent' : undefined }
}) }
} })
try {
for (const [sessionID, child, callID] of [['parent', 'child', 'task'], ['child', 'grandchild', 'nested']]) {
await hooks.event({ event: { type: 'message.part.updated', properties: { part: {
type: 'tool', tool: 'task', sessionID, callID, state: { metadata: { sessionId: child } }
} } } })
}
write.mockClear()
await Promise.all([
hooks.event({ event: { type: 'question.asked', properties: question } }),
hooks.event({ event: { type: 'question.asked', properties: question } })
])
expect(write).toHaveBeenCalledOnce()
const notification = JSON.parse(write.mock.calls[0]![0]).params
expect(notification.sessionId).toBe('parent')
const extension = notification.update._meta.goodbuddyQuestion
expect(extension.childCallId).toBe('task')
const send = (url: string, sessionId: string, answers: string[][]) => fetch(url, {
method: 'POST', body: JSON.stringify({ questionId: 'q', sessionId, answers })
})
expect((await send(extension.endpoint.replace(/.$/u, '!'), 'parent', [['A']])).status).toBe(404)
expect((await send(extension.endpoint, 'other', [['A']])).status).toBe(409)
expect(post).not.toHaveBeenCalled()
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(200)
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reply', body: { answers: [['A']] }, throwOnError: true })
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(409)
listed = []
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect(write).toHaveBeenCalledOnce()
listed = [question]
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect((await send(extension.endpoint, 'parent', [])).status).toBe(200)
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reject', throwOnError: true })
await hooks.dispose()
await expect(send(extension.endpoint, 'parent', [])).rejects.toThrow()
write.mockClear()
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect(write).not.toHaveBeenCalled()
} finally {
await hooks.dispose()
}
})
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => { it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
const write = vi.fn() const write = vi.fn()
const factory = runInNewContext( const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace('export default ', '')})`, `(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
{ process: { stdout: { write } } } { process: { stdout: { write } }, createServer: undefined, randomBytes: undefined }
) )
const hooks = await factory() const hooks = await factory()
const event = { const event = {
+142 -9
View File
@@ -1,20 +1,98 @@
/** /**
* OpenCode's ACP adapter omits native child sessions. This plugin forwards * OpenCode's ACP adapter omits native questions and child sessions. Native
* their SDK events as ACP metadata on the owning Task, using the same stdio * events use ACP metadata; question replies use a process-local capability.
* stream and prompt lifetime as the built-in adapter.
*/ */
export function openCodeSubagentPluginSource(): string { export function openCodeSubagentPluginSource(): string {
return `export default ${plugin.toString()}` return `import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
} }
function plugin() { async function plugin(input?: {
client: {
_client: {
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
}
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
data: { id: string; parentID?: string }
}> }
}
}, modules?: {
createServer: typeof import('node:http').createServer
randomBytes: typeof import('node:crypto').randomBytes
}) {
const tasks = new Map<string, { sessionId: string; callId: string }>() const tasks = new Map<string, { sessionId: string; callId: string }>()
const pending = new Map<string, { sessionId: string; root: string }>()
let endpoint: string | undefined
let closed = false
let closeServer: (() => void) | undefined
if (input) {
const { createServer, randomBytes } = modules!
const token = randomBytes(32).toString('base64url')
const server = createServer(async (request, response) => {
try {
if (request.method !== 'POST' || request.url !== `/${token}`) {
response.writeHead(404).end()
return
}
let body = ''
for await (const chunk of request) {
body += chunk
if (body.length > 1024 * 1024) throw new Error('Question answer too large')
}
const answer = JSON.parse(body) as {
questionId: string; sessionId: string; answers: string[][]
}
const question = pending.get(answer.questionId)
if (!question || question.root !== answer.sessionId) {
response.writeHead(409).end()
return
}
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
if (!Array.isArray(listed.data) || !listed.data.some((item) =>
item.id === answer.questionId && item.sessionID === question.sessionId
)) {
pending.delete(answer.questionId)
response.writeHead(409).end()
return
}
await input.client._client.post({
url: `/question/${encodeURIComponent(answer.questionId)}/${answer.answers.length ? 'reply' : 'reject'}`,
...(answer.answers.length ? { body: { answers: answer.answers } } : {}),
throwOnError: true
})
pending.delete(answer.questionId)
response.writeHead(200).end('{}')
} catch {
response.writeHead(500).end()
}
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
server.unref()
closeServer = () => { server.closeAllConnections(); server.close() }
const address = server.address()
if (address && typeof address !== 'string') endpoint = `http://127.0.0.1:${address.port}/${token}`
}
return Promise.resolve({ return Promise.resolve({
dispose: async () => {
closed = true
pending.clear()
tasks.clear()
closeServer?.()
},
event: async ({ event }: { event: { event: async ({ event }: { event: {
type: string type: string
properties: { properties: {
id?: string
requestID?: string
status?: { type?: string }
sessionID?: string sessionID?: string
part?: { part?: {
sessionID?: string
type: string type: string
tool?: string tool?: string
callID?: string callID?: string
@@ -25,14 +103,69 @@ function plugin() {
} }
} }) => { } }) => {
const properties = event.properties const properties = event.properties
if (closed) return
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
for (const [id, question] of pending) {
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
pending.delete(id)
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: id,
_meta: { goodbuddyQuestionResolved: id } }
} }) + '\n')
}
for (const [child, owner] of tasks) {
if (owner.sessionId === properties.sessionID) tasks.delete(child)
}
return
}
if (event.type === 'question.replied' || event.type === 'question.rejected') {
const question = properties.requestID ? pending.get(properties.requestID) : undefined
if (question) {
pending.delete(properties.requestID!)
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: properties.requestID,
_meta: { goodbuddyQuestionResolved: properties.requestID } }
} }) + '\n')
}
return
}
if (event.type === 'question.asked' && input && endpoint && properties.id && properties.sessionID) {
if (pending.has(properties.id)) return
let root = properties.sessionID
const seen = new Set<string>()
for (;;) {
if (seen.has(root)) return
seen.add(root)
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
if (session.data.id !== root) return
if (!session.data.parentID) break
root = session.data.parentID
}
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
if (closed || pending.has(properties.id) || !Array.isArray(listed.data) || !listed.data.some((item) =>
item.id === properties.id && item.sessionID === properties.sessionID
)) return
pending.set(properties.id, { sessionId: properties.sessionID, root })
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', method: 'session/update', params: {
sessionId: root, update: {
sessionUpdate: 'tool_call_update', toolCallId: properties.id,
_meta: { goodbuddyQuestion: { question: properties, endpoint,
childCallId: tasks.get(properties.sessionID)?.callId } }
}
}
}) + '\n')
return
}
const part = properties.part const part = properties.part
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined const sessionId = properties.sessionID ?? part?.sessionID
let owner = sessionId ? tasks.get(sessionId) : undefined
if ( if (
!owner && event.type === 'message.part.updated' && event.type === 'message.part.updated' &&
part?.type === 'tool' && part.tool === 'task' && part?.type === 'tool' && part.tool === 'task' &&
part.callID && properties.sessionID part.callID && sessionId
) { ) {
owner = { sessionId: properties.sessionID, callId: part.callID } owner ??= { sessionId, callId: part.callID }
const child = part.state?.metadata?.sessionId const child = part.state?.metadata?.sessionId
if (child) tasks.set(child, owner) if (child) tasks.set(child, owner)
} }
+48 -1
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import { chmodSync, mkdtempSync, rmSync } from 'node:fs' import { chmodSync, mkdtempSync, rmSync, statSync } from 'node:fs'
import type { Socket } from 'node:net' import type { Socket } from 'node:net'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path'
@@ -18,6 +18,8 @@ import {
import { ControllerRegistry, type ControllerLease } from './controller-registry' import { ControllerRegistry, type ControllerLease } from './controller-registry'
import { EventJournal } from './event-journal' import { EventJournal } from './event-journal'
import { AgentProtocolServer } from './protocol-server' import { AgentProtocolServer } from './protocol-server'
import { WorkspacePathAccess } from './workspace-path-access'
import { remoteWorkspaceReadTextRequestSchema } from '../shared/remote-agent-contracts'
const temporaryPaths: string[] = [] const temporaryPaths: string[] = []
const closeMethod = 'channel/close' const closeMethod = 'channel/close'
@@ -341,6 +343,51 @@ describe('AgentProtocolServer connection bounds', () => {
harness.close() harness.close()
}) })
it('preserves ENOENT from a real workspace missing-file read', async () => {
const root = temporaryDirectory()
const metadata = statSync(root)
const workspace = new WorkspacePathAccess({
canonicalPath: root,
device: metadata.dev.toString(),
inode: metadata.ino.toString(),
workspaceIdentity: 'workspace-test'
})
const harness = createHarness({
methods: {
'workspace/readText': (params) =>
workspace.readText(remoteWorkspaceReadTextRequestSchema.parse(params))
}
})
try {
harness.socket.receive(
controlFrame(harness.controller, 'missing-file', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'workspace/readText',
params: {
workspaceId: 'workspace-test',
generation: 1,
relativePath: 'missing.txt',
offsetBytes: 0,
maximumBytes: 100
}
})
)
await waitFor(() => harness.socket.writes.length === 2)
expect(jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))).toEqual({
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: expect.stringContaining('ENOENT'),
data: { code: 'ENOENT' }
}
})
} finally {
harness.close()
}
})
it('returns strict status, doctor, and exact implemented capabilities', async () => { it('returns strict status, doctor, and exact implemented capabilities', async () => {
const methods = Object.fromEntries( const methods = Object.fromEntries(
[ [
+1 -1
View File
@@ -1137,7 +1137,7 @@ function typedErrorCode(error: unknown): string | undefined {
typeof error !== 'object' || typeof error !== 'object' ||
!('code' in error) || !('code' in error) ||
typeof error.code !== 'string' || typeof error.code !== 'string' ||
!/^[a-z][a-z0-9-]{0,63}$/u.test(error.code) !/^(?:[a-z][a-z0-9-]{0,63}|E[A-Z0-9]{1,63})$/u.test(error.code)
) { ) {
return undefined return undefined
} }
@@ -0,0 +1,143 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { expect, it } from 'vitest'
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
import { SemanticPromptStore } from './semantic-prompt-store'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
import { createOpenCodeModelBridgeProviderConfig } from './model-bridge-helper'
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
const binary = process.env.GOODBUDDY_TEST_OPENCODE_BINARY ?? join(process.cwd(),
'.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode')
it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as const)(
'round trips native ACP %s questions through the production plugin and Agent owner', async (scenario) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-question-'))
await mkdir(join(root, 'state'))
const pluginPath = join(root, 'plugin.mjs')
await writeFile(pluginPath, openCodeSubagentPluginSource())
let calls = 0
let answered = false
let tools: string[] = []
const model = createServer((request, response) => {
void (async () => {
let body = ''
for await (const chunk of request) body += chunk.toString()
const input = JSON.parse(body)
tools = (input.tools ?? []).map((tool: { function: { name: string } }) => tool.function.name)
if (++calls > 4) { response.writeHead(500).end(); return }
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
const results = messages.filter(message => message.role === 'tool')
answered ||= JSON.stringify(results).includes('REMOTE_ANSWER') ||
(scenario === 'reject' && JSON.stringify(results).includes('dismissed'))
const childPrompt = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)).includes('CHILD_DECISION')
const task = scenario === 'child' && !childPrompt && results.length === 0
const finished = results.length > 0
response.writeHead(200, { 'content-type': 'text/event-stream' })
for (const [delta, finish] of [
[finished ? { role: 'assistant', content: 'QUESTION_OK' } : {
role: 'assistant', tool_calls: [{ index: 0, id: task ? 'native-task' : 'native-question', type: 'function',
function: { name: task ? 'task' : 'question', arguments: JSON.stringify(task ? {
subagent_type: 'general', description: 'Ask child question', prompt: 'CHILD_DECISION: Ask a question.'
} : { questions: [
{ header: 'Details', question: 'Remote decision?', options: [], custom: true }
] }) } }]
}, null], [{}, finished ? 'stop' : 'tool_calls']
]) response.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk',
created: 1, model: 'fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`)
response.end('data: [DONE]\n\n')
})().catch(() => response.destroy())
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No fixture port')
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions',
model: 'fixture', loopbackOrigin: `http://127.0.0.1:${address.port}/${'a'.repeat(43)}`, workMode: 'execute' })
const child = spawn(binary, ['acp'], { cwd: root, stdio: 'pipe', env: {
...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, plugin: [pathToFileURL(pluginPath).href] }),
OPENCODE_CONFIG_DIR: join(process.cwd(), '.runtime-resources', 'opencode-config'),
OPENCODE_DISABLE_PROJECT_CONFIG: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true',
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
OPENCODE_DISABLE_AUTOUPDATE: 'true', XDG_DATA_HOME: join(root, 'data'),
XDG_STATE_HOME: join(root, 'state'), XDG_CONFIG_HOME: join(root, 'config')
} })
let stderr = ''
child.stderr.on('data', data => { stderr += data.toString() })
const transcript = new SemanticPromptStore(join(root, 'state', 'prompts.sqlite'))
transcript.prepare({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
let complete = false
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
workspaceDirectory: root, workMode: 'execute', transcript,
process: {
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
const onData = (data: Uint8Array) => listener({ stream: 'stdout', data })
child.stdout.on('data', onData)
return () => { child.stdout.off('data', onData) }
},
subscribeExit: (listener: () => void) => { child.on('exit', listener); return () => { child.off('exit', listener) } }
} as unknown as RuntimeAcpProcessOwner,
completePrompt: () => { complete = true }
})
try {
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
let questionId: string | undefined
await expect.poll(async () => {
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
controllerId: 'controller', afterSequence: '0', limit: 100 })
for (const event of page.events) {
const notification = event.payload as { update?: { _meta?: { goodbuddyQuestion?: {
question: { id: string }; endpoint?: string; childCallId?: string
} } } }
const extension = notification.update?._meta?.goodbuddyQuestion
if (extension && !questionId) {
expect(extension.endpoint).toBeUndefined()
if (scenario === 'child') expect(extension.childCallId).toBe('native-task')
questionId = extension.question.id
expect(owner.pendingQuestions('other')).toEqual([])
expect(owner.pendingQuestions('operation')).toHaveLength(1)
await expect(owner.respondToQuestion({ bindingId: 'other', operationId: 'operation', questionId,
answers: [['WRONG']] })).rejects.toThrow('no longer pending')
if (scenario === 'cancel') {
await owner.cancel()
expect(owner.pendingQuestions('operation')).toEqual([])
continue
}
await owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation', questionId,
answers: scenario === 'reject' ? [] : [['REMOTE_ANSWER']] })
}
}
return complete
}, { timeout: 30_000, interval: 50 }).toBe(true)
expect(tools).toContain('question')
expect(questionId, stderr).toBeDefined()
if (scenario === 'reject') {
expect(JSON.stringify(transcript.page({ bindingId: 'binding', operationId: 'operation',
controllerId: 'controller', afterSequence: '0', limit: 100 }).events)).toContain('dismissed')
} else if (scenario !== 'cancel') expect(answered).toBe(true)
expect(owner.pendingQuestions('operation')).toEqual([])
expect(calls).toBe(scenario === 'child' ? 4 : scenario === 'reject' || scenario === 'cancel' ? 1 : 2)
await expect(owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation',
questionId: questionId!, answers: [['LATE']] })).rejects.toThrow('no longer pending')
} catch (error) {
console.error({ calls, tools, stderr, events: transcript.page({ bindingId: 'binding',
operationId: 'operation', controllerId: 'controller', afterSequence: '0', limit: 100 }).events })
throw error
} finally {
owner.close()
child.kill()
await new Promise<void>(resolve => { if (child.exitCode !== null) resolve(); else child.once('exit', () => resolve()) })
transcript.close()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(root, { recursive: true, force: true })
}
}, 60_000)
+18 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { remoteQuestionResponseSchema } from '../shared/remote-question-contracts'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { import {
AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_LIMITS,
@@ -421,6 +422,20 @@ export class RuntimeAcpBackend {
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)), this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
'runtime/attachPrompt': (params, context) => 'runtime/attachPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)), this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
'runtime/respondToQuestion': (params, context) =>
this.#enqueuePublicControl(async () => {
const request = remoteQuestionResponseSchema.parse(params)
this.#assertControllerLive(context)
const binding = this.#bindings.get(request.bindingId)
if (!binding?.ownedAcp || binding.activeOperationId !== request.operationId ||
binding.controllerId !== context.controller.controllerId ||
binding.controllerGeneration !== context.controller.generation ||
binding.connectionId !== context.controller.connectionId) {
throw new RuntimeAcpBackendError('Question reply identity does not match', 'identity')
}
await binding.ownedAcp.respondToQuestion(request)
return {}
}),
'runtime/pagePromptTranscript': (params, context) => 'runtime/pagePromptTranscript': (params, context) =>
this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)), this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)),
'runtime/ackPromptTranscript': (params, context) => 'runtime/ackPromptTranscript': (params, context) =>
@@ -1476,10 +1491,12 @@ export class RuntimeAcpBackend {
'process' 'process'
) )
} }
return this.#options.semanticPrompts.page({ const page = this.#options.semanticPrompts.page({
...request, ...request,
controllerId: context.controller.controllerId controllerId: context.controller.controllerId
}) })
const binding = this.#bindings.get(request.bindingId)
return { ...page, pendingQuestions: binding?.ownedAcp?.pendingQuestions(request.operationId) ?? [] }
} }
#ackOwnedPrompt( #ackOwnedPrompt(
@@ -170,6 +170,7 @@ describe('production Runtime composition', () => {
'runtime/preparePrompt', 'runtime/preparePrompt',
'runtime/reconcilePrompt', 'runtime/reconcilePrompt',
'runtime/replayAcpChannel', 'runtime/replayAcpChannel',
'runtime/respondToQuestion',
'runtime/resumeAcpChannel', 'runtime/resumeAcpChannel',
'runtime/startPrompt' 'runtime/startPrompt'
]) ])
+76
View File
@@ -2872,6 +2872,82 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
).not.toHaveProperty('modelBridge') ).not.toHaveProperty('modelBridge')
}) })
it('restores live questions after transcript ACK, scopes replies and ignores answered history', async () => {
const fixture = ownedChannel()
const reply = vi.fn<NonNullable<RemoteRuntimeChannel['respondToQuestion']>>()
.mockRejectedValueOnce(new Error('temporary response failure'))
.mockResolvedValue(undefined)
fixture.channel.respondToQuestion = reply
let answered = false
let pages = 0
const question = { sessionId: 'owned-session', update: {
sessionUpdate: 'tool_call_update', toolCallId: 'native-question', _meta: {
goodbuddyQuestion: { question: { id: 'native-question', sessionID: 'owned-session',
questions: [{ header: 'Input', question: 'Which?', options: [] }] } }
}
} }
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
latestSequence: answered ? '2' : '1', acknowledgedSequence: '1', hasMore: false,
pendingQuestions: answered ? [] : [question],
events: answered ? [
{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 },
{ sequence: '2', kind: 'prompt-terminal', payload: { status: 'completed', response: { stopReason: 'end_turn' } }, createdAt: 2 }
] : (++pages === 1 ? [] : [{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 }])
})
let id: string | undefined
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
if (event.type !== 'question') continue
expect(id).toBeUndefined()
id = event.questionId
expect(id).not.toBe('native-question')
expect(event.questions[0]).toMatchObject({ custom: true, multiple: false })
await expect(fixture.instance.respondToQuestion(id, [['A']])).rejects.toThrow('temporary response failure')
await fixture.instance.respondToQuestion(id, [['A']])
answered = true
}
expect(id).toBeDefined()
expect(reply).toHaveBeenLastCalledWith({ bindingId: expect.any(String), operationId: request.requestId,
questionId: 'native-question', answers: [['A']] })
await expect(fixture.instance.respondToQuestion(id!, [['late']])).rejects.toThrow('no longer pending')
expect(reply).toHaveBeenCalledTimes(2)
await fixture.instance.dispose()
})
it('drops reply mappings when the Agent resolves a question while the prompt remains active', async () => {
const fixture = ownedChannel()
const reply = vi.fn(async () => undefined)
fixture.channel.respondToQuestion = reply
let pages = 0
let answered = false
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
latestSequence: answered ? '1' : '0', acknowledgedSequence: '0', hasMore: false,
pendingQuestions: answered ? [] : [{ sessionId: 'owned-session', update: {
sessionUpdate: 'tool_call_update', toolCallId: 'question', _meta: { goodbuddyQuestion: {
question: { id: ++pages === 1 ? 'first' : 'second', sessionID: 'owned-session',
questions: [{ header: 'Input', question: 'Which?', options: [] }] }
} }
} }],
events: answered ? [{ sequence: '1', kind: 'prompt-terminal', createdAt: 1,
payload: { status: 'completed', response: { stopReason: 'end_turn' } } }] : []
})
let previous: string | undefined
const resolved: string[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
if (event.type === 'question-resolved') resolved.push(event.questionId)
if (event.type !== 'question') continue
if (!previous) { previous = event.questionId; continue }
await expect(fixture.instance.respondToQuestion(previous, [['stale']])).rejects.toThrow('no longer pending')
expect(reply).not.toHaveBeenCalled()
await fixture.instance.respondToQuestion(event.questionId)
answered = true
}
expect(reply).toHaveBeenCalledOnce()
expect(resolved).toEqual([previous])
await fixture.instance.dispose()
})
it('freshly attaches an accepted prompt without resending it', async () => { it('freshly attaches an accepted prompt without resending it', async () => {
const fixture = ownedChannel() const fixture = ownedChannel()
const first = fixture.instance.run( const first = fixture.instance.run(
+60 -1
View File
@@ -1,4 +1,6 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
import type { AgentQuestionAnswer } from '../../shared/contracts'
import { isDeepStrictEqual } from 'node:util' import { isDeepStrictEqual } from 'node:util'
import { import {
ClientSideConnection, ClientSideConnection,
@@ -137,6 +139,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
} }
type ActivePrompt = { type ActivePrompt = {
reportedQuestions?: Set<string>
subagentProgress?: OpenCodeSubagentProgress subagentProgress?: OpenCodeSubagentProgress
requestId: string requestId: string
operationId: string operationId: string
@@ -352,6 +355,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
Promise<SessionRecord> Promise<SessionRecord>
>() >()
private readonly activePrompts = new Map<string, ActivePrompt>() private readonly activePrompts = new Map<string, ActivePrompt>()
private readonly pendingQuestions = new Map<string, { prompt: ActivePrompt; nativeId: string }>()
private readonly sessionReservations = new Set<string>() private readonly sessionReservations = new Set<string>()
private readonly drainWaiters = new Set<() => void>() private readonly drainWaiters = new Set<() => void>()
private readonly contexts = new Map<string, ChannelContext>() private readonly contexts = new Map<string, ChannelContext>()
@@ -2070,6 +2074,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt, prompt: ActivePrompt,
update: SessionUpdate update: SessionUpdate
): RuntimePublicEvent | undefined { ): RuntimePublicEvent | undefined {
const extension = update._meta?.goodbuddyQuestion
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
prompt.open && prompt.context.channel.respondToQuestion) {
const value = extension as { question?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
const questionId = JSON.stringify([prompt.bindingId, prompt.operationId, question.id])
prompt.reportedQuestions ??= new Set()
if (prompt.reportedQuestions.has(questionId)) return undefined
prompt.reportedQuestions.add(questionId)
this.pendingQuestions.set(questionId, { prompt, nativeId: question.id })
const childTaskId = typeof value.childCallId === 'string'
? prompt.toolCalls.get(value.childCallId)?.retainedSubagent?.childTaskId : undefined
return { requestId: prompt.requestId, type: 'question', questionId,
questions: question.questions, ...(childTaskId ? { childTaskId } : {}) }
}
if (this.options.runtimeId === 'opencode') { if (this.options.runtimeId === 'opencode') {
if (!prompt.subagentProgress) { if (!prompt.subagentProgress) {
prompt.subagentProgress = new OpenCodeSubagentProgress( prompt.subagentProgress = new OpenCodeSubagentProgress(
@@ -2295,6 +2314,25 @@ export class AcpRemoteRuntime implements AgentRuntime {
return undefined return undefined
} }
async respondToQuestion(questionId: string, answers: AgentQuestionAnswer[] = []): Promise<void> {
const pending = this.pendingQuestions.get(questionId)
if (!pending || !pending.prompt.open || !pending.prompt.context.channel.respondToQuestion) {
throw new Error('Remote question is no longer pending')
}
const { prompt, nativeId } = pending
this.assertUsable(prompt.context, true)
await this.awaitOperation(prompt.context, '回答问题', prompt.context.channel.respondToQuestion!({
bindingId: prompt.bindingId, operationId: prompt.operationId, questionId: nativeId, answers
}), undefined, false)
this.pendingQuestions.delete(questionId)
}
private clearPromptQuestions(prompt: ActivePrompt): void {
for (const [id, pending] of this.pendingQuestions) {
if (pending.prompt === prompt) this.pendingQuestions.delete(id)
}
}
private withRemoteProvenance( private withRemoteProvenance(
event: RuntimePublicEvent, event: RuntimePublicEvent,
bindingId: string, bindingId: string,
@@ -2553,7 +2591,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
transcriptEvent.payload, transcriptEvent.payload,
session.sessionId session.sessionId
) )
const mapped = this.mapUpdate(prompt, notification.update) const mapped = page.pendingQuestions !== undefined && notification.update._meta?.goodbuddyQuestion
? undefined : this.mapUpdate(prompt, notification.update)
if (mapped !== undefined) { if (mapped !== undefined) {
publicEvents.push(this.limitEvent(mapped)) publicEvents.push(this.limitEvent(mapped))
} }
@@ -2672,6 +2711,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
transcriptPollDelayMs = 100 transcriptPollDelayMs = 100
continue continue
} }
const pendingNotifications = (page.pendingQuestions ?? []).map(question =>
transcriptSessionNotification(question, session.sessionId))
if (page.pendingQuestions !== undefined) {
const liveIds = new Set(pendingNotifications.map(notification => remoteQuestionSchema.parse(
(notification.update._meta?.goodbuddyQuestion as { question?: unknown } | undefined)?.question
).id))
for (const [id, pending] of this.pendingQuestions) {
if (pending.prompt === prompt && !liveIds.has(pending.nativeId)) {
this.pendingQuestions.delete(id)
yield { requestId: prompt.requestId, type: 'question-resolved', questionId: id }
}
}
}
for (const notification of pendingNotifications) {
const event = this.mapUpdate(prompt, notification.update)
if (event?.type === 'question') yield event
}
if ( if (
page.state === 'completed' || page.state === 'completed' ||
page.state === 'failed' || page.state === 'failed' ||
@@ -2699,6 +2755,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
} }
signal.removeEventListener('abort', cancel) signal.removeEventListener('abort', cancel)
prompt.open = false prompt.open = false
this.clearPromptQuestions(prompt)
this.activePrompts.delete(binding.bindingId) this.activePrompts.delete(binding.bindingId)
} }
} }
@@ -3043,6 +3100,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
session.binding = binding session.binding = binding
} }
prompt.open = false prompt.open = false
this.clearPromptQuestions(prompt)
prompt.interrupt = undefined prompt.interrupt = undefined
prompt.wake?.() prompt.wake?.()
prompt.wake = undefined prompt.wake = undefined
@@ -3251,6 +3309,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
new Error('远端 Runtime 已关闭,活动请求结果需要核对') new Error('远端 Runtime 已关闭,活动请求结果需要核对')
) )
prompt.open = false prompt.open = false
this.clearPromptQuestions(prompt)
prompt.wake?.() prompt.wake?.()
this.discardUpdates(prompt) this.discardUpdates(prompt)
} }
+138
View File
@@ -0,0 +1,138 @@
import { execFile, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep'
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
const mocked = { ...actual, execFile: vi.fn(actual.execFile) }
return { ...mocked, default: mocked }
})
const input: WorkspaceRipgrepInput = {
path: '.', pattern: 'target', glob: [], fixedStrings: false,
ignoreCase: false, filesOnly: false, maxResults: 100
}
const match = JSON.stringify({
type: 'match',
data: {
path: { text: './valid.txt' }, lines: { text: 'target\n' },
line_number: 1, submatches: [{ start: 0 }]
}
}) + '\n'
const accessError = 'blocked: Permission denied (os error 13)'
const directories: string[] = []
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
function mockResult(code: number | string, stdout: string, stderr: string) {
const error = Object.assign(new Error(`process failed: ${code}`), { code })
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
if (file !== rgPath) return actual.execFile(file, args, options, callback)
const complete = callback as (error: Error, stdout: string, stderr: string) => void
complete(error, stdout, stderr)
return {} as ChildProcess
})
return error
}
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
return searchWorkspaceWithRipgrep(
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
)
}
afterEach(async () => {
vi.mocked(execFile).mockReset()
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
})
describe('searchWorkspaceWithRipgrep', () => {
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => {
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000))
const result = await search()
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath)
expect(result).toContain('valid.txt:1:1:target\\n')
expect(result).toContain('incomplete search coverage')
expect(result).toContain('missing results do not establish absence')
expect(result).toContain(accessError)
expect(result.length).toBeLessThan(2_500)
})
it('preserves file listings and existing result truncation', async () => {
mockResult(2, './valid.txt\0./other.txt\0', accessError)
const result = await search({ filesOnly: true, maxResults: 1 })
expect(result).toContain('incomplete search coverage')
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]')
expect(result).not.toContain('other.txt')
})
it('qualifies a no-match summary when traversal was incomplete', async () => {
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError)
const result = await search()
expect(result).toContain('incomplete search coverage')
expect(result).toContain('[no matches found in searched files]')
expect(result).not.toContain('[no matches found]')
})
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => {
mockResult(2, '', accessError)
await expect(search({ filesOnly })).rejects.toThrow(accessError)
})
it('does not turn invalid regex diagnostics into successful partial output', async () => {
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class')
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class')
})
it('keeps launch failures as errors even with stdout', async () => {
const error = mockResult('ENOENT', match, '')
await expect(search()).rejects.toMatchObject({ cause: error })
})
it('preserves the cancellation reason even when partial stdout is available', async () => {
const controller = new AbortController()
const reason = new Error('cancel search')
controller.abort(reason)
mockResult(2, match, accessError)
await expect(search({}, controller.signal)).rejects.toBe(reason)
})
it('keeps exit 1 as a normal no-match result', async () => {
mockResult(1, '', '')
await expect(search()).resolves.toBe('[no matches found]')
})
it('preserves capture-limit truncation', async () => {
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
})
it.each([
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
directories.push(directory)
await writeFile(join(directory, 'valid.txt'), 'target\n')
// Add a second target at the process boundary: the public API stats its single target first.
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
)
const result = await searchWorkspaceWithRipgrep(
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
new LocalWorkspaceAccess(directory), new AbortController().signal
)
expect(result).toContain(expected)
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
expect(result).toContain('missing.txt')
})
it('rejects an invalid regex using the real bundled rg', async () => {
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
})
})
+21 -1
View File
@@ -52,6 +52,7 @@ function runRipgrep(
if ( if (
processError && processError &&
processError.code !== 1 && processError.code !== 1 &&
processError.code !== 2 &&
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
) { ) {
reject( reject(
@@ -225,7 +226,17 @@ export async function searchWorkspaceWithRipgrep(
identity.canonicalDisplayPath, identity.canonicalDisplayPath,
signal signal
) )
return input.filesOnly const incomplete = result.code === 2
if (
incomplete &&
(!result.stdout.trim() ||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
) {
throw new Error(
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
)
}
const formatted = input.filesOnly
? formatFileResults( ? formatFileResults(
result.stdout, result.stdout,
input.maxResults, input.maxResults,
@@ -236,4 +247,13 @@ export async function searchWorkspaceWithRipgrep(
input.maxResults, input.maxResults,
result.truncated result.truncated
) )
if (incomplete) {
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
const details = result.stderr.trim().slice(0, 2_000)
const partial = formatted
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
}
return formatted
} }
+22 -38
View File
@@ -29,32 +29,22 @@ const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222' const secondLibraryId = '22222222-2222-4222-8222-222222222222'
function createService() { function createService() {
const searchHybridMany = vi.fn( const retrieveMany = vi.fn(
async (libraryIds: readonly string[]) => async (libraryIds: readonly string[]) =>
libraryIds.map((knowledgeBaseId, index) => ({ libraryIds.map((knowledgeBaseId, index) => ({
knowledgeBaseId, knowledgeBaseId,
result: { response: { diagnostics: {}, results: [{
document: { knowledgeBaseId,
id: `33333333-3333-4333-8333-33333333333${index}`, documentId: `33333333-3333-4333-8333-33333333333${index}`,
title: `文档 ${index}` documentTitle: `文档 ${index}`,
}, sourceDisplayName: `来源 ${index}`,
source: { chunkId: `44444444-4444-4444-8444-444444444440`,
displayName: `来源 ${index}`, location: `第 ${index + 1} 段`,
location: `/private/${index}`
},
chunk: {
id: `44444444-4444-4444-8444-44444444444${index}`,
location: `第 ${index + 1} 段`
},
snippet: `<mark>匹配</mark> ${index}`, snippet: `<mark>匹配</mark> ${index}`,
rank: index + 1, rank: index + 1,
retrieval: { channels: ['fts'] as const,
score: 0.5, scores: { fusedScore: 0.5, ftsRank: 1 }
channels: ['fts'] as const, }] }
lexicalRank: 1,
evidenceIds: []
}
}
})) }))
) )
const service = { const service = {
@@ -72,9 +62,9 @@ function createService() {
} }
] ]
}, },
searchHybridMany retrieveMany
} as unknown as KnowledgeService } as unknown as KnowledgeService
return { service, searchHybridMany } return { service, retrieveMany }
} }
function customMcpServer( function customMcpServer(
@@ -189,24 +179,19 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = [] const gateways: KnowledgeMcpGateway[] = []
it('honors a requested knowledge result count above eight', async () => { it('honors a requested knowledge result count above eight', async () => {
const { service, searchHybridMany } = createService() const { service, retrieveMany } = createService()
const [sample] = await searchHybridMany([firstLibraryId]) const [sample] = await retrieveMany([firstLibraryId])
searchHybridMany.mockClear() retrieveMany.mockClear()
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({ retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({
...sample!, ...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1
result: { })) } }])
...sample!.result,
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
rank: index + 1
}
})))
const gateway = new KnowledgeMcpGateway(service) const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway) gateways.push(gateway)
const controller = new AbortController() const controller = new AbortController()
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)! const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
const references = await gateway.search(token, { query: 'evidence', limit: 12 }) const references = await gateway.search(token, { query: 'evidence', limit: 12 })
expect(references).toHaveLength(12) expect(references).toHaveLength(12)
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal) expect(retrieveMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', expect.any(AbortSignal))
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow() await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
}) })
const databases: AssistantDatabase[] = [] const databases: AssistantDatabase[] = []
@@ -621,7 +606,7 @@ describe('KnowledgeMcpGateway', () => {
}) })
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => { it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
const { service, searchHybridMany } = createService() const { service, retrieveMany } = createService()
const gateway = new KnowledgeMcpGateway(service) const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway) gateways.push(gateway)
const token = gateway.grant( const token = gateway.grant(
@@ -652,10 +637,9 @@ describe('KnowledgeMcpGateway', () => {
limit: 1 limit: 1
}) })
expect(searchHybridMany).toHaveBeenCalledWith( expect(retrieveMany).toHaveBeenCalledWith(
[secondLibraryId], [secondLibraryId],
'要找什么', '要找什么',
1,
expect.any(AbortSignal) expect.any(AbortSignal)
) )
expect(references).toEqual([ expect(references).toEqual([
+16 -32
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
import { import {
createServer, createServer,
type IncomingMessage, type IncomingMessage,
@@ -24,7 +25,6 @@ import type {
BrowserTabSummary, BrowserTabSummary,
KnowledgeSearchReference KnowledgeSearchReference
} from '../../shared/contracts' } from '../../shared/contracts'
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
import { import {
knowledgeToolNames, knowledgeToolNames,
knowledgeScopedDataToolCatalog, knowledgeScopedDataToolCatalog,
@@ -273,13 +273,7 @@ function textContent(value: string): MagicNoteRichContent {
} }
function referenceKey(reference: KnowledgeSearchReference): string { function referenceKey(reference: KnowledgeSearchReference): string {
return [ return knowledgeReferenceKey(reference)
reference.libraryId,
reference.documentId,
reference.chunkId ?? '',
reference.locator ?? '',
reference.snippet
].join('\0')
} }
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult { function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
@@ -681,20 +675,22 @@ export class KnowledgeMcpGateway {
const { query, limit } = knowledgeSearchTool.inputSchema.parse( const { query, limit } = knowledgeSearchTool.inputSchema.parse(
input input
) )
const effectiveSignal = signal const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
? AbortSignal.any([signal, capability.signal])
: capability.signal
effectiveSignal.throwIfAborted() effectiveSignal.throwIfAborted()
const libraries = this.knowledgeService.database.listKnowledgeBases(500) const libraries = this.knowledgeService.database.listKnowledgeBases(500)
const libraryNames = new Map( const libraryNames = new Map(
libraries.map((library) => [library.id, library.name]) libraries.map((library) => [library.id, library.name])
) )
const results = await this.knowledgeService.searchHybridMany( const outcomes = await this.knowledgeService.retrieveMany(
capability.libraryIds, capability.libraryIds,
query, query,
limit,
effectiveSignal effectiveSignal
) )
effectiveSignal.throwIfAborted()
const warnings = outcomes.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
if (outcomes.length && warnings.length === outcomes.length) throw new Error(warnings.join('; '))
const results = outcomes.flatMap(item => item.response.results.map(result => ({ knowledgeBaseId: item.knowledgeBaseId, result })))
if (!results.length && warnings.length) throw new Error(warnings.join('; '))
const references: KnowledgeSearchReference[] = [] const references: KnowledgeSearchReference[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const { knowledgeBaseId, result } of results.sort( for (const { knowledgeBaseId, result } of results.sort(
@@ -704,22 +700,8 @@ export class KnowledgeMcpGateway {
break break
} }
const reference: KnowledgeSearchReference = { const reference: KnowledgeSearchReference = {
libraryId: knowledgeBaseId, ...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'),
libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库', ...(warnings.length ? { warnings } : {})
documentId: result.document.id,
chunkId: result.chunk.id,
documentName: result.document.title.slice(0, 500),
sourceName: result.source.displayName.slice(0, 500),
locator: result.chunk.location?.slice(0, 1_000),
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 12_000),
rank: result.rank,
score: result.retrieval.score,
lexicalRank: result.retrieval.lexicalRank,
vectorRank: result.retrieval.vectorRank,
graphRank: result.retrieval.graphRank,
similarity: result.retrieval.similarity,
retrievalChannels: result.retrieval.channels,
evidenceIds: result.retrieval.evidenceIds?.slice(0, 100)
} }
const key = referenceKey(reference) const key = referenceKey(reference)
if (seen.has(key)) { if (seen.has(key)) {
@@ -1473,13 +1455,14 @@ export class KnowledgeMcpGateway {
private async callScopedTool( private async callScopedTool(
token: string, token: string,
name: ScopedDataToolName, name: ScopedDataToolName,
input: unknown input: unknown,
signal?: AbortSignal
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
switch (name) { switch (name) {
case 'knowledge_list': case 'knowledge_list':
return { libraries: this.listLibraries(token, input) } return { libraries: this.listLibraries(token, input) }
case 'knowledge_search': case 'knowledge_search':
return { references: await this.search(token, input) } return { references: await this.search(token, input, signal) }
case 'note_list': case 'note_list':
return { notes: this.listMagicNotes(token, input) } return { notes: this.listMagicNotes(token, input) }
case 'note_get': case 'note_get':
@@ -1641,7 +1624,8 @@ export class KnowledgeMcpGateway {
await this.callScopedTool( await this.callScopedTool(
token, token,
name as ScopedDataToolName, name as ScopedDataToolName,
parsedInput parsedInput,
extra.signal
) )
) )
} }
+98
View File
@@ -4084,6 +4084,104 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
}) })
it('recovers an absolute workspace read by retrying a relative path against the real filesystem', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-read-recovery-'))
const workspaceAccess = new LocalWorkspaceAccess(workspace)
const toolProvider = new ModelToolProvider(workspaceAccess, [])
const absolutePath = join(workspace, 'README.md')
const marker = `read-recovery-${crypto.randomUUID()}`
const responses = [absolutePath, 'README.md'].map((path, index) => ({
choices: [{ message: {
role: 'assistant',
content: null,
tool_calls: [{
id: `read-${index}`,
type: 'function',
function: {
name: 'workspace_read_text',
arguments: JSON.stringify({ path })
}
}]
} }]
}))
const fetcher = vi.fn<typeof fetch>(async () => {
const response = responses.shift()
return Response.json(response ?? {
choices: [{ message: { role: 'assistant', content: 'Read completed.' } }]
})
})
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1',
model: 'test-model',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider,
workspaceAccess
})
const events: RuntimeEvent[] = []
try {
await writeFile(absolutePath, `${marker}\n`)
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: 'conversation-workspace-read-recovery',
prompt: 'Read README.md.',
workMode: 'ask'
}, new AbortController().signal, async () => 'deny')) {
events.push(event)
}
expect(fetcher).toHaveBeenCalledTimes(3)
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as {
messages: Array<{ role: string; tool_call_id?: string; content: string }>
}
const correction = secondBody.messages.filter((message) => message.role === 'tool')
expect(correction).toHaveLength(1)
expect(correction[0]).toMatchObject({ tool_call_id: 'read-0' })
expect(JSON.parse(correction[0]!.content)).toEqual({
ok: false,
recoverable: true,
error: expect.stringMatching(/^workspace_read_text: [\s\S]*路径必须相对于工作区/u),
nextAction: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed. Do not retry identical arguments.'
})
expect(correction[0]!.content).not.toContain(marker)
const thirdBody = JSON.parse(String(fetcher.mock.calls[2]![1]!.body)) as {
messages: Array<{ role: string; tool_call_id?: string; content: string }>
}
expect(thirdBody.messages.filter((message) => message.role === 'tool')).toEqual([
correction[0],
expect.objectContaining({
tool_call_id: 'read-1',
content: expect.stringContaining(marker)
})
])
const toolEvents = events.filter((event) => event.type === 'tool')
expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([
['read-0', 'pending'],
['read-0', 'running'],
['read-0', 'recoverable'],
['read-1', 'pending'],
['read-1', 'running'],
['read-1', 'completed']
])
expect(JSON.parse(toolEvents[0]!.input!)).toEqual({ path: absolutePath })
expect(toolEvents[2]).toMatchObject({
error: expect.stringContaining('路径必须相对于工作区')
})
expect(JSON.parse(toolEvents[3]!.input!)).toEqual({ path: 'README.md' })
expect(toolEvents[5]).toMatchObject({ output: expect.stringContaining(marker) })
expect(events).toContainEqual(expect.objectContaining({
type: 'text', delta: 'Read completed.'
}))
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
await runtime.dispose()
await rm(workspace, { recursive: true, force: true })
}
})
it('streams OpenAI Responses text and reasoning through tool rounds', async () => { it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
const streams = [ const streams = [
createSseEventStream([ createSseEventStream([
+153
View File
@@ -68,6 +68,7 @@ vi.mock('../capabilities/mcp-client-transport', () => ({
import { import {
ModelToolProvider, ModelToolProvider,
RecoverableModelToolError,
type ModelToolCallContext, type ModelToolCallContext,
type ModelSubagentRequestContext, type ModelSubagentRequestContext,
type ModelToolResult type ModelToolResult
@@ -461,6 +462,158 @@ describe('ModelToolProvider', () => {
).resolves.toBe('saved') ).resolves.toBe('saved')
}) })
it('recovers an absolute local path and reads the corrected relative path', async () => {
const workspace = await createWorkspace()
await writeFile(join(workspace, 'note.txt'), 'corrected read')
const provider = new ModelToolProvider(workspace)
const signal = new AbortController().signal
try {
await expect(provider.callTool('workspace_read_text', {
path: join(workspace, 'note.txt')
}, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('workspace-relative path')
})
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('corrected read') }] })
await expect(provider.callTool('workspace_read_text', { path: 'missing.txt' }, signal, toolContext))
.rejects.toMatchObject({
name: 'RecoverableModelToolError', cause: { code: 'ENOENT' },
nextAction: expect.stringContaining('Do not retry identical arguments')
})
} finally {
await provider.dispose()
}
})
it('recovers invalid search inputs and actual rg regex failures', async () => {
const workspace = await createWorkspace()
await writeFile(join(workspace, 'note.txt'), '[literal')
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const signal = new AbortController().signal
try {
for (const args of [
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
]) {
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('adjust the search path/globs')
})
}
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
} finally {
await provider.dispose()
}
})
it('bounds expected remote read errors without recovering disconnects or programmer errors', async () => {
const access = new LocalWorkspaceAccess(await createWorkspace())
const readText = vi.spyOn(access, 'readText')
const provider = new ModelToolProvider(access)
const signal = new AbortController().signal
try {
for (const code of ['ENOENT', 'EACCES', 'invalid-path', 'invalid-utf8', 'special-file']) {
const failure = Object.assign(new Error('detail'.repeat(1_000)), { data: { code } })
readText.mockRejectedValueOnce(failure)
const result = await provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)
.catch((error: unknown) => error)
expect(result).toBeInstanceOf(RecoverableModelToolError)
const error = result as RecoverableModelToolError
expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(2_021)
expect(error.cause).toBe(failure)
expect(error.nextAction).not.toContain('process_execute')
}
for (const failure of [
new TypeError('programmer error'), new Error('remote disconnected'),
Object.assign(new Error('closed'), { code: 'closed' }),
Object.assign(new Error('stale workspace'), { data: { code: 'stale-workspace' } })
]) {
readText.mockRejectedValueOnce(failure)
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
.rejects.toBe(failure)
}
} finally {
await provider.dispose()
}
})
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace)
const failure = new Error('regex parse error', {
cause: Object.assign(new Error('process failed'), { code: 2 })
})
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
})
try {
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
.catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(RecoverableModelToolError)
expect(error).toMatchObject({ cause: { code: 'ENOENT' } })
} finally {
await provider.dispose()
}
})
it.each(['workspace_read_text', 'workspace_rg'])('keeps %s cancellation fatal', async (name) => {
const access = new LocalWorkspaceAccess(await createWorkspace())
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
try {
const abortError = new DOMException('cancelled', 'AbortError')
operation.mockRejectedValueOnce(abortError)
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError)
const controller = new AbortController()
const reason = new Error('cancelled during access')
operation.mockImplementationOnce(async () => {
controller.abort(reason)
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason)
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
} finally {
await provider.dispose()
}
})
it('suggests process_execute only when available in local model Execute', async () => {
const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace)
const identity = await access.getIdentity()
const processService = {
getCapability: vi.fn(async () => ({ available: true, shell: { kind: 'powershell', label: 'pwsh' } })),
dispose: vi.fn(async () => {})
} as unknown as DirectModelProcessService
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { processService })
try {
for (const [context, available] of [
[{ ...toolContext, runtimeTarget: 'model' as const }, true],
[{ ...toolContext, runtimeTarget: 'model' as const, workMode: 'ask' as const }, false],
[toolContext, false],
[{ ...toolContext, runtimeTarget: 'model' as const, executionSpaceIdentity: `${identity.id}-other` }, false]
] as const) {
const error = await provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, context)
.catch((error: unknown) => error) as RecoverableModelToolError
expect(error).toBeInstanceOf(RecoverableModelToolError)
expect(error.nextAction.includes('process_execute')).toBe(available)
}
vi.mocked(processService.getCapability).mockResolvedValueOnce({ available: false, reason: 'unavailable' })
await expect(provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, {
...toolContext, runtimeTarget: 'model'
})).rejects.toMatchObject({ nextAction: expect.not.stringContaining('process_execute') })
} finally {
await provider.dispose()
}
})
it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => { it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => {
const readText = vi.fn(async () => ({ const readText = vi.fn(async () => ({
path: 'remote.txt', path: 'remote.txt',
+67 -18
View File
@@ -1731,25 +1731,74 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw error throw error
} }
} }
if (name === 'workspace_rg') { if (name === 'workspace_rg' || name === 'workspace_read_text') {
if (!this.programming.ripgrepExecutablePath) { try {
throw new Error('GoodBuddy 内置 ripgrep 不可用') if (name === 'workspace_rg') {
} if (!this.programming.ripgrepExecutablePath) {
const input = ripgrepInputSchema.parse(argumentsValue) throw new Error('GoodBuddy 内置 ripgrep 不可用')
return createTextToolResult( }
await searchWorkspaceWithRipgrep( const input = ripgrepInputSchema.parse(argumentsValue)
this.programming.ripgrepExecutablePath, return createTextToolResult(
input, await searchWorkspaceWithRipgrep(
this.workspaceAccess, this.programming.ripgrepExecutablePath,
signal input,
this.workspaceAccess,
signal
)
)
}
const input = readInputSchema.parse(argumentsValue)
return createTextToolResult(
await readWorkspaceLines(this.workspaceAccess, input, signal)
) )
) } catch (error) {
} signal.throwIfAborted()
if (name === 'workspace_read_text') { if (!(error instanceof Error) || error.name === 'AbortError' ||
const input = readInputSchema.parse(argumentsValue) (error.cause instanceof Error && error.cause.name === 'AbortError')) {
return createTextToolResult( throw error
await readWorkspaceLines(this.workspaceAccess, input, signal) }
) const code = 'code' in error ? error.code : undefined
const data = 'data' in error ? error.data : undefined
const remoteCode = data && typeof data === 'object' && 'code' in data
? data.code : undefined
const expectedOsCodes = ['ENOENT', 'ENOTDIR', 'EISDIR', 'EACCES', 'EPERM', 'ELOOP', 'ENAMETOOLONG']
const expectedWorkspaceError = [
'路径必须是工作区内的相对路径',
'路径必须是工作区内的相对路径,不能超出工作区',
'文件路径不能超出项目工作区',
'文件路径不能通过符号链接超出项目工作区',
'目标不是普通文件',
'目标不是目录',
'工作区读取目标不是有效 UTF-8 文本',
'工作区读取范围无效'
].includes(error.message)
const rgExecutionError = name === 'workspace_rg' && (
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
)
if (!(error instanceof z.ZodError) &&
!(typeof code === 'string' && expectedOsCodes.includes(code)) &&
!(typeof remoteCode === 'string' && [
...expectedOsCodes, 'invalid-path', 'invalid-utf8',
'not-directory', 'special-file', 'symlink-rejected'
].includes(remoteCode)) &&
!expectedWorkspaceError && !rgExecutionError) {
throw error
}
let nextAction = name === 'workspace_rg'
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).'
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
nextAction += ' Do not retry identical arguments.'
if ((await this.getProcessTool(context, signal)).length > 0) {
nextAction += ' For files outside the workspace, use process_execute in local Execute mode.'
}
signal.throwIfAborted()
throw new RecoverableModelToolError(
`${name}: ${utf8Prefix(error.message, 2_000)}`,
nextAction,
{ cause: error }
)
}
} }
if (name === 'workspace_apply_patch') { if (name === 'workspace_apply_patch') {
const input = applyPatchInputSchema.parse(argumentsValue) const input = applyPatchInputSchema.parse(argumentsValue)
@@ -14,7 +14,7 @@ const cachedBinary = join(
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
it.skipIf(!existsSync(binaryPath))( it.skipIf(!existsSync(binaryPath))(
'completes native Task external reads and structured yes/no and text questions while Ask disables tools', 'completes native Task external reads and concurrent structured questions while Ask disables tools',
async () => { async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-')) const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
const workspace = join(root, 'workspace') const workspace = join(root, 'workspace')
@@ -50,7 +50,8 @@ it.skipIf(!existsSync(binaryPath))(
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } } tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
} }
} else if (results.some(result => result.tool_call_id === 'native-question')) { } else if (results.some(result => result.tool_call_id === 'native-question')) {
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer') answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer') &&
results.some(result => result.tool_call_id === 'parallel-question' && JSON.stringify(result.content).includes('Parallel answer'))
content = 'PARENT_OK' content = 'PARENT_OK'
} else if (results.some(result => result.tool_call_id === 'native-task')) { } else if (results.some(result => result.tool_call_id === 'native-task')) {
tool = { id: 'native-question', name: 'question', arguments: { questions: [ tool = { id: 'native-question', name: 'question', arguments: { questions: [
@@ -74,7 +75,11 @@ it.skipIf(!existsSync(binaryPath))(
role: 'assistant', role: 'assistant',
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: { tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
name: tool.name, arguments: JSON.stringify(tool.arguments) name: tool.name, arguments: JSON.stringify(tool.arguments)
} }] } }, ...(tool.id === 'native-question' ? [{ index: 1, id: 'parallel-question', type: 'function', function: {
name: 'question', arguments: JSON.stringify({ questions: [
{ header: 'Parallel', question: 'Another decision?', options: [], custom: true }
] })
} }] : [])]
} : { role: 'assistant', content }, null], } : { role: 'assistant', content }, null],
[{}, tool ? 'tool_calls' : 'stop'] [{}, tool ? 'tool_calls' : 'stop']
]) { ]) {
@@ -102,17 +107,24 @@ it.skipIf(!existsSync(binaryPath))(
try { try {
for (const mode of ['execute', 'ask'] as const) { for (const mode of ['execute', 'ask'] as const) {
let text = '' let text = ''
const pending: Array<{ id: string; answers: string[][] }> = []
for await (const event of runtime.run({ for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS' workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
}, AbortSignal.timeout(30_000))) { }, AbortSignal.timeout(30_000))) {
if (event.type === 'text') text += event.delta if (event.type === 'text') text += event.delta
if (event.type === 'question') { if (event.type === 'question') {
expect(event.questions).toHaveLength(2) pending.push({ id: event.questionId, answers: event.questions.length === 2
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']]) ? [['Yes'], ['Custom answer']] : [['Parallel answer']] })
// Both native calls must reach the desktop before either is answered.
if (pending.length === 2) {
expect(pending[0]!.id).not.toBe(pending[1]!.id)
for (const question of pending) await runtime.respondToQuestion(question.id, question.answers)
}
} }
} }
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK') expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
expect(pending).toHaveLength(mode === 'execute' ? 2 : 0)
} }
expect(readVerified).toBe(true) expect(readVerified).toBe(true)
expect(answerVerified).toBe(true) expect(answerVerified).toBe(true)
+47
View File
@@ -2281,6 +2281,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose(); await runtime.dispose();
}); });
it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => {
const question = (id: string, sessionID: string) => ({
type: "question.asked", properties: { id, sessionID,
questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] },
});
const setup = runClient([
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
id: "task-part", callID: "task-call", type: "tool", tool: "task",
state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" },
metadata: { sessionId: "child-session" }, time: { start: 1 } },
} } },
question("first", "child-session"),
question("second", "other-child"),
question("first", "child-session"),
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
id: "task-part", callID: "task-call", type: "tool", tool: "task",
state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" },
output: "Finished", time: { start: 1, end: 2 } },
} } },
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never);
const runtime = embeddedRuntime(setup.client);
const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel",
prompt: "test", workMode: "execute" }, new AbortController().signal);
await stream.next();
const task = (await stream.next()).value;
expect(task?.type).toBe("subagent");
const first = (await stream.next()).value;
const second = (await stream.next()).value;
if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions");
expect(first.childTaskId).toBe(task.childTaskId);
expect(second.childTaskId).toBeUndefined();
expect(first.questionId).not.toBe(second.questionId);
setup.questionReply.mockRejectedValueOnce(new Error("Reply failed"));
await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed");
await runtime.respondToQuestion(first.questionId, [["Answer"]]);
await runtime.respondToQuestion(second.questionId);
expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything());
expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything());
const remaining = [];
for await (const event of stream) remaining.push(event);
expect(remaining.some((event) => event.type === "question")).toBe(false);
await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow();
await runtime.dispose();
});
it("namespaces identical upstream question IDs across concurrent external conversations", async () => { it("namespaces identical upstream question IDs across concurrent external conversations", async () => {
const setup = runClient([]); const setup = runClient([]);
( (
+6
View File
@@ -2464,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime {
return false; return false;
}; };
const reportedMessageIds = new Set<string>(); const reportedMessageIds = new Set<string>();
const childTaskIdsBySession = new Map<string, string>();
let waitingForRetry = false; let waitingForRetry = false;
for await (const event of subscription.stream) { for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event); const childProgress = subagentProgress.update(event);
@@ -2621,6 +2622,10 @@ export class OpenCodeRuntime implements AgentRuntime {
...(subagent ? { subagent } : {}), ...(subagent ? { subagent } : {}),
}); });
if (subagent) { if (subagent) {
const metadata = "metadata" in part.state ? part.state.metadata : undefined;
if (isRecord(metadata) && typeof metadata.sessionId === "string") {
childTaskIdsBySession.set(metadata.sessionId, subagent.childTaskId);
}
const retained = subagentProgress.retain(subagent); const retained = subagentProgress.retain(subagent);
toolStates.get(callId)!.subagent = retained; toolStates.get(callId)!.subagent = retained;
yield retained; yield retained;
@@ -2693,6 +2698,7 @@ export class OpenCodeRuntime implements AgentRuntime {
requestId: request.requestId, requestId: request.requestId,
type: "question", type: "question",
questionId: publicQuestionId, questionId: publicQuestionId,
childTaskId: childTaskIdsBySession.get(event.properties.sessionID),
questions: questionRequest.questions.map((question) => ({ questions: questionRequest.questions.map((question) => ({
header: question.header, header: question.header,
question: question.question, question: question.question,
+2
View File
@@ -18,6 +18,7 @@ import type {
remoteSemanticTranscriptPageResultSchema, remoteSemanticTranscriptPageResultSchema,
} from '../../shared/remote-agent-contracts' } from '../../shared/remote-agent-contracts'
import type { z } from 'zod' import type { z } from 'zod'
import type { RemoteQuestionResponse } from '../../shared/remote-question-contracts'
import type { import type {
ModelBridgePolicy ModelBridgePolicy
} from '../../shared/model-bridge-contracts' } from '../../shared/model-bridge-contracts'
@@ -91,6 +92,7 @@ export interface RemoteModelBridgeSession {
* The runtime intentionally receives no SSH client, socket or command API. * The runtime intentionally receives no SSH client, socket or command API.
*/ */
export interface RemoteRuntimeChannel { export interface RemoteRuntimeChannel {
respondToQuestion?(request: RemoteQuestionResponse): Promise<void>
readonly input: ReadableStream<Uint8Array> readonly input: ReadableStream<Uint8Array>
readonly output: WritableStream<Uint8Array> readonly output: WritableStream<Uint8Array>
readonly generation: number readonly generation: number
+153 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises' import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@@ -132,6 +133,148 @@ function claimManualScheduleQueueItem(
} }
describe('AssistantDatabase', () => { describe('AssistantDatabase', () => {
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
const database = await createDatabase()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
try {
const projectId = database.listProjects()[0]!.id
const otherProject = database.createProject({
name: 'Other project', description: '', rootPath: 'C:\\Other',
defaultWorkMode: 'ask'
})
const active = ['running', 'waiting_approval'].map((status, index) =>
database.createTask({
id: randomUUID(), projectId: index ? otherProject.id : projectId,
title: status, instructions: '', workMode: 'execute',
status: status as 'running' | 'waiting_approval'
})
)
database.createTask({
id: randomUUID(), title: 'Hidden live task', instructions: '',
workMode: 'execute', visible: false
})
const history = Array.from({ length: 501 }, (_, index) => {
vi.setSystemTime(new Date(Date.UTC(2026, 1, 1) + index * 1000))
return database.createTask({
id: randomUUID(), projectId, title: `History ${index}`,
instructions: '', workMode: 'ask',
status: index === 500 ? 'running' : 'completed'
})
}).reverse()
const expected = [
...history.slice(0, Math.max(1, Math.min(500, limit))),
...active
]
const tasks = database.listTasks(limit)
expect(tasks).toHaveLength(expected.length)
expect(new Set(tasks.map((task) => task.id))).toEqual(
new Set(expected.map((task) => task.id))
)
expect(tasks.slice(0, expected.length - active.length)).toEqual(
history.slice(0, Math.max(1, Math.min(500, limit)))
)
expect(tasks.find((task) => task.id === active[1]!.id)?.projectId)
.toBe(otherProject.id)
if (limit === 100) {
expect(database.listTasks()).toEqual(tasks)
}
} finally {
database.close()
}
})
it('includes old live schedule and streaming conversations beyond recent history without exposing hidden or archived entries', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-live-history-'))
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const raw = new DatabaseSync(databasePath)
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
try {
const projectId = database.listProjects()[0]!.id
const schedules = ['running', 'approval', 'pending', 'idle', 'hidden', 'archived']
.map((title) => database.createSchedule({
projectId, title, prompt: title, recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00Z'
}))
const [running, approval, pending, , hidden, archived] = schedules
for (const schedule of [running!, approval!, hidden!, archived!]) {
claimManualScheduleQueueItem(database, schedule.id)
}
database.updateTaskStatus(approval!.taskId, 'waiting_approval')
database.queueScheduleNow(pending!.id)
database.updateTaskStatus(pending!.taskId, 'running')
raw.prepare('UPDATE tasks SET visible = 0 WHERE id = ?').run(hidden!.taskId)
raw.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?")
.run(archived!.conversationId)
// Queued schedule tasks normally read as idle, unless a run overrides them.
raw.prepare("UPDATE tasks SET status = 'queued' WHERE id = ?").run(running!.taskId)
expect(database.listTasks().find((task) => task.id === pending!.taskId)?.status)
.toBe('queued')
const liveConversations = ['running', 'waiting_approval'].map((status) => {
const id = randomUUID()
database.saveLocalConversations([{
header: { id, projectId, title: status, updatedAt: 1 }, messages: []
}])
database.createTask({
id: randomUUID(), projectId, conversationId: id,
title: status, instructions: '', workMode: 'execute',
status: status as 'running' | 'waiting_approval'
})
return id
})
const streamingId = randomUUID()
const streamingMessage = {
id: randomUUID(), role: 'assistant' as const, content: 'Still working',
state: 'streaming' as const, createdAt: 1
}
database.saveLocalConversations([{
header: { id: streamingId, projectId, title: 'Streaming', updatedAt: 1 },
messages: [streamingMessage]
}])
const recent = Array.from({ length: 101 }, (_, index) => ({
header: {
id: randomUUID(), projectId, title: `Recent ${index}`,
updatedAt: Date.UTC(2026, 1, 1) + index * 1000
},
messages: []
}))
database.saveLocalConversations(recent)
vi.setSystemTime(new Date('2026-02-01T00:00:00Z'))
const historyTask = database.createTask({
id: randomUUID(), projectId, title: 'Recent completed task',
instructions: '', workMode: 'ask', status: 'completed'
})
const tasks = database.listTasks(1)
expect(tasks).toHaveLength(6)
expect(tasks).toEqual(expect.arrayContaining([
historyTask,
expect.objectContaining({ id: running!.taskId, status: 'running' }),
expect.objectContaining({ id: approval!.taskId, status: 'waiting_approval' }),
expect.objectContaining({ id: archived!.taskId, status: 'running' })
]))
const conversations = database.listConversations()
expect(conversations).toHaveLength(105)
expect(new Set(conversations.map((conversation) => conversation.id))).toEqual(
new Set([
...recent.slice(1).map((conversation) => conversation.header.id),
running!.conversationId, approval!.conversationId, streamingId,
...liveConversations
])
)
expect(conversations.find((conversation) => conversation.id === streamingId))
.toMatchObject({ projectId, messages: [streamingMessage] })
expect(conversations.slice(0, 100).map((conversation) => conversation.id))
.toEqual(recent.slice(1).reverse().map((conversation) => conversation.header.id))
} finally {
raw.close()
database.close()
}
})
it('keeps timed messages independent of creation-time execution settings', async () => { it('keeps timed messages independent of creation-time execution settings', async () => {
const database = await createDatabase() const database = await createDatabase()
const project = database.createProject({ const project = database.createProject({
@@ -3460,7 +3603,13 @@ describe('AssistantDatabase', () => {
snippet: '可以继续恢复。', snippet: '可以继续恢复。',
rank: 1, rank: 1,
retrievalChannels: ['fts'] retrievalChannels: ['fts']
} },
...['first', 'second', 'first'].map(remoteChunkId => ({
libraryId: '00000000-0000-4000-8000-000000000708',
libraryName: 'External', documentName: 'Remote', sourceName: 'Remote',
snippet: 'same excerpt', rank: 2,
external: { kind: 'external' as const, provider: 'dify' as const, instanceId: 'instance', remoteKnowledgeBaseId: 'dataset', remoteChunkId }
}))
] ]
}) })
append('8', 0, { append('8', 0, {
@@ -3489,7 +3638,9 @@ describe('AssistantDatabase', () => {
resultCount: 1 resultCount: 1
}, },
sourceReferences: [ sourceReferences: [
expect.objectContaining({ documentName: '恢复说明.md' }) expect.objectContaining({ documentName: '恢复说明.md' }),
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'first' }) }),
expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second' }) })
], ],
subagents: [ subagents: [
expect.objectContaining({ expect.objectContaining({
+25 -14
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { import {
builtInDefaultProjectSeedDescription, builtInDefaultProjectSeedDescription,
@@ -588,6 +589,14 @@ const taskSelect = `SELECT tasks.*,
LIMIT 1) AS active_run_status LIMIT 1) AS active_run_status
FROM tasks` FROM tasks`
// Match toTask's live statuses: pending schedule runs normalize to queued.
const activeVisibleTaskSelect = `${taskSelect}
WHERE visible = 1 AND (
active_run_status = 'running'
OR (active_run_status IS NULL
AND status IN ('running', 'waiting_approval'))
)`
function toTask(row: TaskRow): AssistantTask { function toTask(row: TaskRow): AssistantTask {
return { return {
id: row.id, id: row.id,
@@ -1470,16 +1479,7 @@ function reduceRecoveredAgentEvent(
: undefined : undefined
} }
} else if (event.type === 'source-references') { } else if (event.type === 'source-references') {
const key = ( const key = knowledgeReferenceKey
reference: (typeof event.references)[number]
): string =>
[
reference.libraryId,
reference.documentId,
reference.chunkId ?? '',
reference.locator ?? '',
reference.snippet
].join('\0')
const incoming = [ const incoming = [
...new Map( ...new Map(
event.references.map((reference) => [key(reference), reference]) event.references.map((reference) => [key(reference), reference])
@@ -2612,8 +2612,15 @@ export class AssistantDatabase {
updated_at updated_at
FROM conversations FROM conversations
WHERE status = 'active' WHERE status = 'active'
ORDER BY updated_at DESC AND (
LIMIT 100` id IN (SELECT id FROM conversations WHERE status = 'active'
ORDER BY updated_at DESC LIMIT 100)
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
OR EXISTS (SELECT 1 FROM messages
WHERE conversation_id = conversations.id
AND state = 'streaming')
)
ORDER BY updated_at DESC`
) )
.all() as ConversationRow[] .all() as ConversationRow[]
const messageStatement = database.prepare( const messageStatement = database.prepare(
@@ -4195,8 +4202,12 @@ export class AssistantDatabase {
.prepare( .prepare(
`${taskSelect} `${taskSelect}
WHERE visible = 1 WHERE visible = 1
ORDER BY created_at DESC AND (
LIMIT ?` id IN (SELECT id FROM tasks WHERE visible = 1
ORDER BY created_at DESC LIMIT ?)
OR id IN (SELECT id FROM (${activeVisibleTaskSelect}))
)
ORDER BY created_at DESC`
) )
.all(safeLimit) as TaskRow[] .all(safeLimit) as TaskRow[]
return rows.map(toTask) return rows.map(toTask)
+1
View File
@@ -854,6 +854,7 @@ if (hasSingleInstanceLock) {
runtimeExtensionStore.markStartupFailed(extensionIds) runtimeExtensionStore.markStartupFailed(extensionIds)
}) })
const startupKnowledgeService = new KnowledgeService({ const startupKnowledgeService = new KnowledgeService({
credentialCipher: secureCipher,
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'), databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
managedRoot: join(app.getPath('userData'), 'knowledge'), managedRoot: join(app.getPath('userData'), 'knowledge'),
extractStructured: createModelGraphExtractor(settingsStore), extractStructured: createModelGraphExtractor(settingsStore),
+65 -12
View File
@@ -25,6 +25,7 @@ import type {
} from '../shared/ssh-host-contracts' } from '../shared/ssh-host-contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database' import { AssistantDatabase } from './assistant/assistant-database'
import { KnowledgeService } from './knowledge/knowledge-service'
import { BrowserNavigationStoppedError } from './browser/browser-service' import { BrowserNavigationStoppedError } from './browser/browser-service'
import { import {
registerIpcHandlers, registerIpcHandlers,
@@ -3087,6 +3088,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => {
it('exposes per-library ontology settings and rebuild state', async () => { it('exposes per-library ontology settings and rebuild state', async () => {
const libraryId = '11111111-1111-4111-8111-111111111111' const libraryId = '11111111-1111-4111-8111-111111111111'
const knowledgeService = { const knowledgeService = {
database: { externalStore: { listBindings: () => [] } },
snapshot: vi.fn(() => ({ snapshot: vi.fn(() => ({
libraries: [ libraries: [
{ {
@@ -8236,7 +8238,7 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('preflights always-retrieve mode and injects bounded untrusted evidence', async () => { it.each([false, true])('preflights always-retrieve mode with external=%s and injects bounded untrusted evidence', async (external) => {
const libraryId = '11111111-1111-4111-8111-111111111111' const libraryId = '11111111-1111-4111-8111-111111111111'
const documentId = '33333333-3333-4333-8333-333333333333' const documentId = '33333333-3333-4333-8333-333333333333'
const chunkId = '44444444-4444-4444-8444-444444444444' const chunkId = '44444444-4444-4444-8444-444444444444'
@@ -8291,15 +8293,16 @@ describe('registerIpcHandlers agent terminal state', () => {
results: [ results: [
{ {
knowledgeBaseId: libraryId, knowledgeBaseId: libraryId,
documentId, documentId: external ? undefined : documentId,
sourceId: '55555555-5555-4555-8555-555555555555', sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555',
chunkId, chunkId: external ? undefined : chunkId,
external: external ? { kind: 'external' as const, provider: 'dify' as const, instanceId: libraryId, remoteKnowledgeBaseId: 'remote-kb', remoteChunkId: 'remote-chunk', location: 'Remote section 3' } : undefined,
documentTitle: '离线部署.md', documentTitle: '离线部署.md',
sourceDisplayName: '产品手册', sourceDisplayName: '产品手册',
sourceType: 'file' as const, sourceType: 'file' as const,
location: '第 2 节', location: '第 2 节',
snippet: '离线部署需要先校验安装包', snippet: '离线部署需要先校验安装包',
relevance: 0.9, relevance: external ? undefined : 0.9,
rank: 1, rank: 1,
channels: ['fts' as const], channels: ['fts' as const],
scores: { scores: {
@@ -8323,6 +8326,10 @@ describe('registerIpcHandlers agent terminal state', () => {
] ]
} }
} }
if (external) {
retrievalResponse.context.groups = []
retrievalResponse.results.push({ ...retrievalResponse.results[0]!, rank: 2, external: { ...retrievalResponse.results[0]!.external!, remoteChunkId: 'second-chunk' } })
}
const retrieveMany = vi.fn(async () => [ const retrieveMany = vi.fn(async () => [
{ knowledgeBaseId: libraryId, response: retrievalResponse } { knowledgeBaseId: libraryId, response: retrievalResponse }
]) ])
@@ -8337,6 +8344,7 @@ describe('registerIpcHandlers agent terminal state', () => {
runtimeId: 'model', runtimeId: 'model',
capability: 'chat', capability: 'chat',
supportsToolExecution: true, supportsToolExecution: true,
supportsScopedDataTools: !external,
run run
}, },
undefined, undefined,
@@ -8387,20 +8395,19 @@ describe('registerIpcHandlers agent terminal state', () => {
expect.objectContaining({ expect.objectContaining({
type: 'knowledge-retrieval', type: 'knowledge-retrieval',
state: 'succeeded', state: 'succeeded',
resultCount: 1 resultCount: external ? 2 : 1
}), }),
expect.objectContaining({ expect.objectContaining({
type: 'source-references', type: 'source-references',
references: [ references: external ? expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }),
chunkId, expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) })
documentId ]) : [expect.objectContaining({ chunkId, documentId })]
})
]
}) })
]) ])
) )
expect(run).toHaveBeenCalledOnce() expect(run).toHaveBeenCalledOnce()
if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"')
await harness.dispose() await harness.dispose()
}) })
@@ -8480,6 +8487,52 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose() await harness.dispose()
}) })
it('routes external UI payloads through IPC, the production service and citation search', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-external-ipc-'))
const fetcher = vi.fn<typeof fetch>(async (_url, init) => Response.json(init?.method === 'POST'
? { records: [{ score: 0.75, segment: { id: 'remote-chunk', content: 'Policy evidence', document: { id: 'remote-document', name: 'Policy' } } }] }
: { data: [{ id: 'dataset', name: 'Policy' }], has_more: false }))
const service = new KnowledgeService({
databasePath: join(directory, 'knowledge.sqlite'), managedRoot: join(directory, 'managed'),
externalFetcher: fetcher,
credentialCipher: { isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() }
})
await service.initialize()
const harness = createHarness({ capability: 'chat' }, undefined, 'always', undefined, false, undefined, service as unknown as Record<string, unknown>)
const event = trustedEvent(harness.webContents)
const invoke = (channel: string, input?: unknown) => electronMocks.handlers.get(channel)!(event, input)
try {
const instance = await invoke(ipcChannels.externalInstancesSave, {
name: 'External instance', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
credential: { action: 'replace', value: 'test-key' }
}) as { id: string }
expect(instance).not.toHaveProperty('credential')
await expect(invoke(ipcChannels.externalCatalogList, { instanceId: instance.id })).resolves.toMatchObject({ items: [{ id: 'dataset' }] })
await expect(invoke(ipcChannels.externalBindingsCreate, { instanceId: instance.id })).rejects.toThrow()
const snapshot = await invoke(ipcChannels.externalBindingsCreate, {
instanceId: instance.id, remoteKnowledgeBaseId: 'dataset', name: 'Policy', remoteName: 'Policy',
testQuery: 'policy', commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 4000 },
providerConfig: { provider: 'dify', useDatasetDefaults: true }
}) as { libraries: Array<{ id: string; kind: string }> }
expect(snapshot.libraries[0]?.kind).toBe('external')
const libraryId = snapshot.libraries[0]!.id
await expect(invoke(ipcChannels.knowledgeRetrieve, { knowledgeBaseId: libraryId, query: 'policy' })).resolves.toMatchObject({
results: [{ external: { remoteChunkId: 'remote-chunk' }, snippet: 'Policy evidence' }]
})
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).resolves.toMatchObject([
{ libraryId, documentId: undefined, snippet: 'Policy evidence', external: { remoteDocumentId: 'remote-document', remoteChunkId: 'remote-chunk', providerScore: 0.75 } }
])
await invoke(ipcChannels.externalInstancesSetEnabled, { instanceId: instance.id, enabled: false })
fetcher.mockClear()
await expect(invoke(ipcChannels.knowledgeSearch, { libraryIds: [libraryId], query: 'policy' })).rejects.toThrow('EXTERNAL_KB_DISABLED')
expect(fetcher).not.toHaveBeenCalled()
} finally {
await harness.dispose()
await service.dispose()
await rm(directory, { recursive: true, force: true })
}
})
it('routes status and concurrent conversations to their selected runtimes', async () => { it('routes status and concurrent conversations to their selected runtimes', async () => {
const firstProfileId = '00000000-0000-4000-8000-000000000001' const firstProfileId = '00000000-0000-4000-8000-000000000001'
const secondProfileId = '00000000-0000-4000-8000-000000000002' const secondProfileId = '00000000-0000-4000-8000-000000000002'
+48 -51
View File
@@ -1,3 +1,5 @@
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
import { import {
app, app,
BrowserWindow, BrowserWindow,
@@ -992,6 +994,8 @@ function getKnowledgeSnapshot(
} }
return { return {
libraries: snapshot.libraries.map((library) => ({ libraries: snapshot.libraries.map((library) => ({
kind: service.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) ? 'external' : 'local',
external: service.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===library.id),
id: library.id, id: library.id,
name: library.name, name: library.name,
description: library.description ?? '', description: library.description ?? '',
@@ -1092,6 +1096,7 @@ function buildForcedKnowledgeEvidence(
promptContext?: string promptContext?: string
references: KnowledgeSearchReference[] references: KnowledgeSearchReference[]
} { } {
const seen = new Set<string>()
const ranked = entries const ranked = entries
.flatMap((entry) => .flatMap((entry) =>
entry.response.results.map((result) => ({ entry.response.results.map((result) => ({
@@ -1104,35 +1109,26 @@ function buildForcedKnowledgeEvidence(
) )
.sort( .sort(
(left, right) => (left, right) =>
right.result.relevance - left.result.relevance || left.result.rank - right.result.rank ||
right.result.scores.fusedScore - left.entry.libraryId.localeCompare(right.entry.libraryId)
left.result.scores.fusedScore ||
left.result.chunkId.localeCompare(right.result.chunkId)
) )
.filter(({ entry, result }) => {
const key = knowledgeReferenceKey(toKnowledgeReference(result, entry.libraryName))
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 20) .slice(0, 20)
const references: KnowledgeSearchReference[] = ranked.map( const references: KnowledgeSearchReference[] = ranked.map(
({ entry, result }, index) => ({ ({ entry, result }, index) => ({
libraryId: entry.libraryId, ...toKnowledgeReference(result, entry.libraryName),
libraryName: entry.libraryName, rank: index + 1
documentId: result.documentId,
chunkId: result.chunkId,
documentName: result.documentTitle,
sourceName: result.sourceDisplayName,
locator: result.location,
snippet: stripKnowledgeHighlightTags(result.snippet),
rank: index + 1,
score: result.scores.fusedScore,
lexicalRank: result.scores.ftsRank,
vectorRank: result.scores.vectorRank,
graphRank: result.scores.graphRank,
similarity: result.scores.vectorSimilarity,
retrievalChannels: result.channels
}) })
) )
if (ranked.length === 0) { if (ranked.length === 0) {
return { references } return { references }
} }
let remainingCharacters = 24_000 let remainingCharacters = 48_000
const evidence: Array<{ const evidence: Array<{
citation: number citation: number
library: string library: string
@@ -1161,7 +1157,7 @@ function buildForcedKnowledgeEvidence(
library: item.entry.libraryName, library: item.entry.libraryName,
document: item.result.documentTitle, document: item.result.documentTitle,
source: item.result.sourceDisplayName, source: item.result.sourceDisplayName,
locator: item.result.location, locator: item.result.external?.location ?? item.result.location,
text text
}) })
remainingCharacters -= text.length remainingCharacters -= text.length
@@ -4317,12 +4313,7 @@ export function registerIpcHandlers(
request.knowledgeCapabilityToken request.knowledgeCapabilityToken
) ?? []) ) ?? [])
].map((reference) => [ ].map((reference) => [
[ knowledgeReferenceKey(reference),
reference.libraryId,
reference.documentId,
reference.chunkId ?? '',
reference.locator ?? ''
].join('\0'),
reference reference
]) ])
).values() ).values()
@@ -4379,6 +4370,9 @@ export function registerIpcHandlers(
libraryNames.get(knowledgeBaseId) ?? '知识库', libraryNames.get(knowledgeBaseId) ?? '知识库',
response response
})) }))
if (entries.length && entries.every(entry => entry.response.diagnostics.failure)) {
throw new Error(entries.map(entry => `${entry.libraryName}: ${entry.response.diagnostics.failure}`).join('; '))
}
const evidence = buildForcedKnowledgeEvidence(entries) const evidence = buildForcedKnowledgeEvidence(entries)
preflightReferences = evidence.references preflightReferences = evidence.references
const usedChannels = [ const usedChannels = [
@@ -4390,13 +4384,13 @@ export function registerIpcHandlers(
) )
] ]
const warnings = entries.flatMap((entry) => const warnings = entries.flatMap((entry) =>
entry.response.diagnostics.degradedChannels.map( [...(entry.response.diagnostics.failure ? [`${entry.libraryName} · ${entry.response.diagnostics.failure}`] : []), ...entry.response.diagnostics.degradedChannels.map(
(item) => (item) =>
`${entry.libraryName} · ${item.reason}`.slice( `${entry.libraryName} · ${item.reason}`.slice(
0, 0,
500 500
) )
) )]
) )
if (evidence.promptContext) { if (evidence.promptContext) {
executionRequest = { executionRequest = {
@@ -4641,6 +4635,10 @@ export function registerIpcHandlers(
runtime: selectedRuntime runtime: selectedRuntime
}) })
} }
if (publicEvent.type === 'question-resolved' &&
pendingAgentQuestions.get(publicEvent.questionId)?.requestId === request.requestId) {
pendingAgentQuestions.delete(publicEvent.questionId)
}
if (publicEvent.type === 'error') { if (publicEvent.type === 'error') {
runtimeErrorEvent = publicEvent runtimeErrorEvent = publicEvent
if (provenance === undefined) { if (provenance === undefined) {
@@ -7901,6 +7899,16 @@ export function registerIpcHandlers(
} }
) )
registerHandler(ipcChannels.externalInstancesList, (event) => {assertTrustedSender(event,window);return knowledgeService.external.listInstances()})
registerHandler(ipcChannels.externalInstancesSave, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.saveInstance(externalKnowledgeInstanceSaveInputSchema.parse(input))})
registerHandler(ipcChannels.externalInstancesTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
registerHandler(ipcChannels.externalInstancesSetEnabled, (event,input:unknown) => {assertTrustedSender(event,window);const value=externalKnowledgeInstanceEnabledInputSchema.parse(input);return knowledgeService.external.setEnabled(value.instanceId,value.enabled)})
registerHandler(ipcChannels.externalInstancesDelete, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.deleteInstance(externalKnowledgeInstanceInputSchema.parse(input).instanceId)})
registerHandler(ipcChannels.externalCatalogList, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.listCatalog(externalKnowledgeCatalogListInputSchema.parse(input))})
registerHandler(ipcChannels.externalCatalogGet, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.getCatalog(externalKnowledgeCatalogGetInputSchema.parse(input))})
registerHandler(ipcChannels.externalRetrievalTest, (event,input:unknown) => {assertTrustedSender(event,window);return knowledgeService.external.testRetrieval(externalKnowledgeBindingTestInputSchema.parse(input))})
registerHandler(ipcChannels.externalBindingsCreate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingSaveInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.externalBindingsUpdate, async (event,input:unknown) => {assertTrustedSender(event,window);await knowledgeService.external.saveBinding(externalKnowledgeBindingUpdateInputSchema.parse(input));return getKnowledgeSnapshot(knowledgeService)})
registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => { registerHandler(ipcChannels.knowledgeSnapshot, (event, input: unknown) => {
assertTrustedSender(event, window) assertTrustedSender(event, window)
const libraryId = const libraryId =
@@ -8064,30 +8072,19 @@ export function registerIpcHandlers(
availableLibraries.map((library) => [library.id, library.name]) availableLibraries.map((library) => [library.id, library.name])
) )
const results = ( const results = (
await knowledgeService.searchHybridMany( await knowledgeService.retrieveMany(
libraries, libraries,
value.query, value.query
6
) )
).map(({ knowledgeBaseId, result }) => ({ )
libraryId: knowledgeBaseId, const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
libraryName: names.get(knowledgeBaseId) ?? '知识库', if (failures.length === results.length) throw new Error(failures.join('; '))
documentId: result.document.id, const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({
chunkId: result.chunk.id, ...toKnowledgeReference(result, names.get(knowledgeBaseId) ?? '知识库'),
documentName: result.document.title, ...(failures.length ? { warnings: failures } : {})
sourceName: result.source.displayName, })))
locator: result.chunk.location, if (!references.length && failures.length) throw new Error(failures.join('; '))
snippet: stripKnowledgeHighlightTags(result.snippet), return [...new Map(references.map(reference => [knowledgeReferenceKey(reference), reference])).values()]
rank: result.rank,
score: result.retrieval.score,
lexicalRank: result.retrieval.lexicalRank,
vectorRank: result.retrieval.vectorRank,
graphRank: result.retrieval.graphRank,
similarity: result.retrieval.similarity,
retrievalChannels: result.retrieval.channels,
evidenceIds: result.retrieval.evidenceIds
}))
return results
.sort((left, right) => left.rank - right.rank) .sort((left, right) => left.rank - right.rank)
.slice(0, 8) .slice(0, 8)
}) })
+220 -3
View File
@@ -1,7 +1,9 @@
import { createServer } from 'node:http'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { import {
ExternalKnowledgeClient, ExternalKnowledgeClient,
ExternalKnowledgeError ExternalKnowledgeError,
type ExternalKnowledgeRetrievalConfig
} from './external-knowledge-client' } from './external-knowledge-client'
const jsonResponse = (body: unknown, status = 200): Response => const jsonResponse = (body: unknown, status = 200): Response =>
@@ -52,7 +54,7 @@ describe('ExternalKnowledgeClient', () => {
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ query: 'leave policy' }) expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ query: 'leave policy' })
}) })
it('filters FastGPT folders and maps searchTest results', async () => { it('retains FastGPT folders for navigation and maps searchTest results', async () => {
const fetcher = vi.fn<typeof fetch>() const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(jsonResponse({ .mockResolvedValueOnce(jsonResponse({
code: 200, code: 200,
@@ -83,7 +85,7 @@ describe('ExternalKnowledgeClient', () => {
}) })
await expect(client.listKnowledgeBases()).resolves.toMatchObject({ await expect(client.listKnowledgeBases()).resolves.toMatchObject({
items: [{ id: 'dataset-1', name: 'Products' }] items: [{ id: 'folder-1', kind: 'folder' }, { id: 'dataset-1', name: 'Products', kind: 'dataset' }]
}) })
await expect(client.retrieve('dataset-1', 'pricing', { await expect(client.retrieve('dataset-1', 'pricing', {
provider: 'fastgpt', provider: 'fastgpt',
@@ -98,6 +100,221 @@ describe('ExternalKnowledgeClient', () => {
})]) })])
expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://fastgpt.example/api/core/dataset/searchTest') expect(String(fetcher.mock.calls[1]?.[0])).toBe('https://fastgpt.example/api/core/dataset/searchTest')
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: null })
})
it.each(['current', 'legacy'] as const)('preserves typed FastGPT scores and result order in the %s envelope', async (envelope) => {
const scores = [{ type: 'fullText', value: 12.4, index: 2 }, { type: 'embedding', value: 0.8, index: 0 }, { type: 'reRank', value: 0.3, index: 1 }]
const list = [{ q: 'First', score: scores, id: 'first' }, { q: 'Second', score: 0.99 }]
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 200, data: envelope === 'legacy' ? list : { list } }))
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const results = await client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'mixedRecall', tokenLimit: 2000, similarity: 0.2, usingRerank: true })
expect(results.map((item) => item.snippet)).toEqual(['First', 'Second'])
expect(results[0]).toMatchObject({ providerScores: scores, remoteChunkId: 'first' })
expect(results[0]?.providerScore).toBeUndefined()
expect(results[0]?.remoteDocumentId).toBeUndefined()
expect(results[1]?.providerScore).toBe(0.99)
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ datasetId: 'dataset', text: 'query', limit: 2000, similarity: 0.2, searchMode: 'mixedRecall', usingReRank: true })
})
it.each([
{ code: 200, data: { data: [] } },
{ code: 500, data: [] },
{ code: 200, data: [{ a: 'Answer without main text' }] },
{ code: 200, data: [{ q: 'Text', score: [{ type: 'embedding', value: '0.8' }] }] }
])('rejects unsupported FastGPT envelopes and malformed evidence', async (body) => {
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
await expect(client.retrieve('dataset', 'query', { provider: 'fastgpt', searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false })).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
})
it('paginates FastGPT directory results locally and traverses parent IDs', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse({ code: 200, data: [
{ _id: 'folder', name: 'Folder', type: 'folder' },
{ _id: 'one', name: 'One', type: 'dataset' },
{ _id: 'two', name: 'Two', type: 'dataset' }
] }))
const client = new ExternalKnowledgeClient({ provider: 'fastgpt', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
await expect(client.listKnowledgeBases(undefined, { parentId: 'parent', page: 2, pageSize: 2 })).resolves.toMatchObject({ items: [{ id: 'two' }], total: 3, hasMore: false })
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ parentId: 'parent' })
await expect(client.listKnowledgeBases(undefined, { search: 'one', pageSize: 1 })).resolves.toMatchObject({ items: [{ id: 'one' }], total: 1, hasMore: false })
})
it.each(['dify', 'ragflow'] as const)('requests subsequent %s pages and keeps continuation when local search has no matches', async (provider) => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify'
? { data: [{ id: 'one', name: 'One' }], total: 5, has_more: true }
: { code: 0, data: [{ id: 'one', name: 'One' }], total_datasets: 5 }))
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
await expect(client.listKnowledgeBases(undefined, { page: 2, pageSize: 1, search: 'missing' })).resolves.toMatchObject({ items: [], hasMore: true })
const url = new URL(String(fetcher.mock.calls[0]?.[0]))
expect(url.searchParams.get('page')).toBe('2')
expect(url.searchParams.get(provider === 'dify' ? 'limit' : 'page_size')).toBe('1')
})
it.each(['dify', 'fastgpt', 'ragflow'] as const)('reads %s details through its fixed read endpoint', async (provider) => {
const record = { id: 'dataset', _id: 'dataset', name: 'Dataset', type: 'dataset', graphrag_task_finish_at: '2026-09-10T10:00:00Z', compilation_template_group_id: 'template' }
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(provider === 'dify' ? record : { code: provider === 'fastgpt' ? 200 : 0, data: provider === 'fastgpt' ? record : [record] }))
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const detail = await client.getKnowledgeBase('dataset')
expect(detail).toMatchObject({ id: 'dataset', name: 'Dataset', kind: 'dataset' })
if (provider === 'ragflow') expect(detail).toMatchObject({ graphEnabled: true, knowledgeCompilationEnabled: true })
expect(String(fetcher.mock.calls[0]?.[0])).toBe(`https://kb.example${provider === 'dify' ? '/v1/datasets/dataset' : provider === 'fastgpt' ? '/api/core/dataset/detail?id=dataset' : '/api/v1/datasets?id=dataset'}`)
expect(fetcher.mock.calls[0]?.[1]?.method).toBe('GET')
})
it('distinguishes missing RAGFlow details and unsupported old Dify details', async () => {
const ragflow = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: [] })) })
await expect(ragflow.getKnowledgeBase('missing')).rejects.toMatchObject({ code: 'EXTERNAL_KB_NOT_FOUND' })
const dify = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 405 })) })
await expect(dify.getKnowledgeBase('old')).rejects.toMatchObject({ code: 'EXTERNAL_KB_INCOMPATIBLE' })
})
it('sends complete explicit Dify configuration and rejects missing reranking identifiers before fetch', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: true, score_threshold: 0.2, reranking_mode: 'reranking_model', reranking_model: { reranking_provider_name: 'provider', reranking_model_name: 'model' } } as const
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, reranking_model: undefined } } as ExternalKnowledgeRetrievalConfig)).rejects.toThrow()
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('does not fetch when already cancelled', async () => {
const controller = new AbortController()
controller.abort(new Error('private reason'))
const fetcher = vi.fn<typeof fetch>()
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const operation = client.listKnowledgeBases(controller.signal)
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
await expect(operation).rejects.not.toHaveProperty('cause')
expect(fetcher).not.toHaveBeenCalled()
})
it.each(['headers', 'body'] as const)('distinguishes cancellation and timeout while reading %s', async (phase) => {
for (const cancellation of [true, false]) {
const controller = new AbortController()
const cancelBody = vi.fn()
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (phase === 'headers') return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
started()
})
return new Response(new ReadableStream({ pull() { started() }, cancel: cancelBody }))
})
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher, timeoutMs: cancellation ? 1000 : 20 })
const operation = client.listKnowledgeBases(controller.signal)
const assertion = expect(operation).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
await ready
if (cancellation) controller.abort()
await assertion
if (phase === 'body') expect(cancelBody).toHaveBeenCalledTimes(1)
}
})
it('maps body transport failures to a sanitized network error', async () => {
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(new Response(new ReadableStream({ pull(controller) { controller.error(new Error('private transport detail')) } }))) })
const operation = client.listKnowledgeBases()
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_NETWORK' })
await expect(operation).rejects.not.toHaveProperty('cause')
})
it.each([200, 401])('cancels a response body when cancellation races with HTTP %s headers', async (status) => {
const controller = new AbortController()
const cancel = vi.fn()
const response = new Response(new ReadableStream({ cancel }), { status })
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => {
controller.abort()
return response
})
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
await expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
expect(cancel).toHaveBeenCalledTimes(1)
expect(response.body?.locked).toBe(false)
})
it.each(['headers', 'body'] as const)('aborts real HTTP requests stalled at %s on cancellation and timeout', async (phase) => {
for (const cancellation of [true, false]) {
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
const server = createServer((_request, response) => {
if (phase === 'body') {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.write('{"data":[')
}
started()
})
try {
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing test server address')
const controller = new AbortController()
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: `http://127.0.0.1:${address.port}`, apiKey: 'test-key', timeoutMs: cancellation ? 2000 : 200 })
const assertion = expect(client.listKnowledgeBases(controller.signal)).rejects.toMatchObject({ code: cancellation ? 'EXTERNAL_KB_CANCELLED' : 'EXTERNAL_KB_TIMEOUT' })
await ready
if (cancellation) controller.abort()
await assertion
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()) })
}
}
})
it.each(['dify', 'fastgpt', 'ragflow'] as const)('validates %s scalar scores without clamping or treating zero as missing', async (provider) => {
for (const score of [0, 12.4, -0.5, undefined, null, '0.8', {}, true]) {
const body = provider === 'dify' ? { records: [{ segment: { content: 'Text' }, score }] }
: provider === 'fastgpt' ? { code: 200, data: { list: [{ q: 'Text', score }] } }
: { code: 0, data: { chunks: [{ content: 'Text', similarity: score }] } }
const config: ExternalKnowledgeRetrievalConfig = provider === 'dify' ? { provider, useDatasetDefaults: true }
: provider === 'fastgpt' ? { provider, searchMode: 'embedding', tokenLimit: 2000, similarity: 0, usingRerank: false }
: { provider, similarityThreshold: 0, vectorSimilarityWeight: 0.3, knnTopK: 128, useKg: false, includeKnowledgeCompilation: false }
const client = new ExternalKnowledgeClient({ provider, baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher: vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(body)) })
const operation = client.retrieve('dataset', 'query', config)
if (score === undefined || score === null || typeof score === 'number') {
await expect(operation).resolves.toEqual([expect.objectContaining({ providerScore: score ?? undefined })])
} else {
await expect(operation).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
}
}
})
it('counts Unicode code points for the Dify API query limit', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const query = '\u{20000}'.repeat(250)
await expect(client.retrieve('dataset', query, { provider: 'dify', useDatasetDefaults: true })).resolves.toEqual([])
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query })
await expect(client.retrieve('dataset', `${query}a`, { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('does not infer RAGFlow capabilities from configuration alone or invalid completion times', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ code: 0, data: ['0', '0.0', '', 'invalid'].map((time, index) => ({ id: String(index), name: 'Dataset', graphrag_task_finish_at: time, parser_config: { graphrag: { use_graphrag: true } } })) }))
const client = new ExternalKnowledgeClient({ provider: 'ragflow', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const page = await client.listKnowledgeBases()
expect(page.items).toHaveLength(4)
for (const item of page.items) expect(item).toMatchObject({ graphEnabled: false, knowledgeCompilationEnabled: false })
})
it('sends Dify weighted configuration without merging and rejects inconsistent weights', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse({ records: [] }))
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', fetcher })
const retrievalModel = { search_method: 'hybrid_search', reranking_enable: true, top_k: 4, score_threshold_enabled: false, reranking_mode: 'weighted_score', weights: { weight_type: 'customized', vector_setting: { vector_weight: 0.7, embedding_provider_name: 'provider', embedding_model_name: 'model' }, keyword_setting: { keyword_weight: 0.3 } } } as const
await client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel })
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ query: 'query', retrieval_model: retrievalModel })
await expect(client.retrieve('dataset', 'query', { provider: 'dify', useDatasetDefaults: false, retrievalModel: { ...retrievalModel, weights: { ...retrievalModel.weights, keyword_setting: { keyword_weight: 0.7 } } } })).rejects.toThrow('weights must sum to one')
await expect(client.retrieve('dataset', 'q'.repeat(251), { provider: 'dify', useDatasetDefaults: true })).rejects.toThrow('250 characters')
expect(fetcher).toHaveBeenCalledTimes(1)
})
it.each([true, false])('cancels oversized response streams (declared length: %s)', async (declared) => {
const cancel = vi.fn()
const response = new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('123456')) }, cancel }), { headers: declared ? { 'content-length': '6' } : {} })
const client = new ExternalKnowledgeClient({ provider: 'dify', baseUrl: 'https://kb.example', apiKey: 'test-key', maximumResponseBytes: 5, fetcher: vi.fn<typeof fetch>().mockResolvedValue(response) })
await expect(client.listKnowledgeBases()).rejects.toMatchObject({ code: 'EXTERNAL_KB_INVALID_RESPONSE' })
expect(cancel).toHaveBeenCalledTimes(1)
expect(response.body?.locked).toBe(false)
}) })
it('maps RAGFlow graph capability and retrieval fields', async () => { it('maps RAGFlow graph capability and retrieval fields', async () => {
+144 -92
View File
@@ -1,44 +1,21 @@
export type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow' import {
externalKnowledgeProviderConfigSchema,
type ExternalKnowledgeProvider,
type ExternalKnowledgeCatalogItem,
type ExternalKnowledgeCatalogPage,
type ExternalKnowledgeProviderConfig,
type ExternalKnowledgeRemoteResult
} from '../../../shared/external-knowledge-contracts'
export type ExternalKnowledgeCatalogItem = { export type { ExternalKnowledgeProvider, ExternalKnowledgeCatalogItem, ExternalKnowledgeCatalogPage }
id: string export type ExternalKnowledgeRetrievalConfig = ExternalKnowledgeProviderConfig
name: string export type ExternalKnowledgeRetrievalResult = ExternalKnowledgeRemoteResult
description?: string
graphEnabled?: boolean
}
export type ExternalKnowledgeCatalogPage = { export type ExternalKnowledgeCatalogInput = {
items: ExternalKnowledgeCatalogItem[] page?: number
total?: number pageSize?: number
hasMore: boolean parentId?: string | null
} search?: string
export type ExternalKnowledgeRetrievalConfig =
| { provider: 'dify'; useDatasetDefaults: true }
| {
provider: 'fastgpt'
searchMode: 'embedding' | 'fullTextRecall' | 'mixedRecall'
tokenLimit: number
similarity: number
usingRerank: boolean
}
| {
provider: 'ragflow'
similarityThreshold: number
vectorSimilarityWeight: number
knnTopK: number
useKg: boolean
includeKnowledgeCompilation: boolean
}
export type ExternalKnowledgeRetrievalResult = {
documentTitle: string
sourceDisplayName: string
snippet: string
providerScore?: number
remoteDocumentId?: string
remoteChunkId?: string
location?: string
} }
export class ExternalKnowledgeError extends Error { export class ExternalKnowledgeError extends Error {
@@ -46,6 +23,8 @@ export class ExternalKnowledgeError extends Error {
readonly code: readonly code:
| 'EXTERNAL_KB_NETWORK' | 'EXTERNAL_KB_NETWORK'
| 'EXTERNAL_KB_TIMEOUT' | 'EXTERNAL_KB_TIMEOUT'
| 'EXTERNAL_KB_CANCELLED'
| 'EXTERNAL_KB_INCOMPATIBLE'
| 'EXTERNAL_KB_AUTH' | 'EXTERNAL_KB_AUTH'
| 'EXTERNAL_KB_FORBIDDEN' | 'EXTERNAL_KB_FORBIDDEN'
| 'EXTERNAL_KB_NOT_FOUND' | 'EXTERNAL_KB_NOT_FOUND'
@@ -60,7 +39,7 @@ export class ExternalKnowledgeError extends Error {
} }
} }
type ExternalKnowledgeClientOptions = { export type ExternalKnowledgeClientOptions = {
provider: ExternalKnowledgeProvider provider: ExternalKnowledgeProvider
baseUrl: string baseUrl: string
apiKey: string apiKey: string
@@ -109,6 +88,7 @@ function endpoint(baseUrl: URL, path: string): URL {
} }
function errorForStatus(status: number): ExternalKnowledgeError { function errorForStatus(status: number): ExternalKnowledgeError {
if (status === 405) return new ExternalKnowledgeError('EXTERNAL_KB_INCOMPATIBLE', 'External knowledge operation is not supported by this deployment')
if (status === 401) return new ExternalKnowledgeError('EXTERNAL_KB_AUTH', 'External knowledge authentication failed') if (status === 401) return new ExternalKnowledgeError('EXTERNAL_KB_AUTH', 'External knowledge authentication failed')
if (status === 403) return new ExternalKnowledgeError('EXTERNAL_KB_FORBIDDEN', 'External knowledge access is forbidden') if (status === 403) return new ExternalKnowledgeError('EXTERNAL_KB_FORBIDDEN', 'External knowledge access is forbidden')
if (status === 404) return new ExternalKnowledgeError('EXTERNAL_KB_NOT_FOUND', 'External knowledge target was not found') if (status === 404) return new ExternalKnowledgeError('EXTERNAL_KB_NOT_FOUND', 'External knowledge target was not found')
@@ -116,36 +96,42 @@ function errorForStatus(status: number): ExternalKnowledgeError {
return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`) return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`)
} }
async function readBoundedJson(response: Response, maximumBytes: number): Promise<unknown> { async function readBoundedJson(response: Response, maximumBytes: number, signal: AbortSignal): Promise<unknown> {
const declaredLength = Number(response.headers.get('content-length')) const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) { if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
void response.body?.cancel().catch(() => {})
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large') throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
} }
if (!response.body) { if (!response.body) {
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty') throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty')
} }
const reader = response.body.getReader() const reader = response.body.getReader()
const cancel = (): void => { void reader.cancel().catch(() => {}) }
signal.addEventListener('abort', cancel, { once: true })
const chunks: Uint8Array[] = [] const chunks: Uint8Array[] = []
let total = 0 let total = 0
try { try {
signal.throwIfAborted()
while (true) { while (true) {
const part = await reader.read() const part = await reader.read()
signal.throwIfAborted()
if (part.done) break if (part.done) break
total += part.value.byteLength total += part.value.byteLength
if (total > maximumBytes) { if (total > maximumBytes) {
await reader.cancel() cancel()
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large') throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large')
} }
chunks.push(part.value) chunks.push(part.value)
} }
} finally { } finally {
signal.removeEventListener('abort', cancel)
reader.releaseLock() reader.releaseLock()
} }
const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8') const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')
try { try {
return JSON.parse(body) as unknown return JSON.parse(body) as unknown
} catch (error) { } catch {
throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON', { cause: error }) throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON')
} }
} }
@@ -169,16 +155,23 @@ export class ExternalKnowledgeClient {
this.maximumResponseBytes = options.maximumResponseBytes ?? 5_000_000 this.maximumResponseBytes = options.maximumResponseBytes ?? 5_000_000
} }
async listKnowledgeBases(signal?: AbortSignal): Promise<ExternalKnowledgeCatalogPage> { async listKnowledgeBases(signal?: AbortSignal, input: ExternalKnowledgeCatalogInput = {}): Promise<ExternalKnowledgeCatalogPage> {
const { page = 1, pageSize = 100, parentId = null } = input
if (!Number.isInteger(page) || page < 1 || page > 500 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
throw new RangeError('Invalid external knowledge catalog pagination')
}
const search = input.search?.trim().toLowerCase()
const filter = (items: ExternalKnowledgeCatalogItem[]): ExternalKnowledgeCatalogItem[] =>
search ? items.filter((item) => `${item.name}\n${item.description ?? ''}`.toLowerCase().includes(search)) : items
if (this.provider === 'dify') { if (this.provider === 'dify') {
const url = endpoint(this.normalizedBaseUrl, '/v1/datasets') const url = endpoint(this.normalizedBaseUrl, '/v1/datasets')
url.searchParams.set('page', '1') url.searchParams.set('page', String(page))
url.searchParams.set('limit', '100') url.searchParams.set('limit', String(pageSize))
const body = asRecord(await this.request(url, { method: 'GET' }, signal)) const body = asRecord(await this.request(url, { method: 'GET' }, signal))
const items = Array.isArray(body?.data) ? body.data : undefined const items = Array.isArray(body?.data) ? body.data : undefined
if (!items) throw this.invalidResponse() if (!items) throw this.invalidResponse()
return { return {
items: items.slice(0, 100).map((item) => this.catalogItem(item)), items: filter(items.slice(0, pageSize).map((item) => this.catalogItem(item))),
total: finiteNumber(body?.total), total: finiteNumber(body?.total),
hasMore: body?.has_more === true hasMore: body?.has_more === true
} }
@@ -187,63 +180,81 @@ export class ExternalKnowledgeClient {
if (this.provider === 'fastgpt') { if (this.provider === 'fastgpt') {
const body = asRecord(await this.request( const body = asRecord(await this.request(
endpoint(this.normalizedBaseUrl, '/core/dataset/list'), endpoint(this.normalizedBaseUrl, '/core/dataset/list'),
{ method: 'POST', body: JSON.stringify({ pageNum: 1, pageSize: 100 }) }, { method: 'POST', body: JSON.stringify({ parentId }) },
signal signal
)) ))
const data = asRecord(body?.data) const items = Array.isArray(body?.data) ? body.data : undefined
const items = Array.isArray(body?.data)
? body.data
: Array.isArray(data?.data)
? data.data
: Array.isArray(data?.list)
? data.list
: undefined
if (body?.code !== 200 || !items) throw this.invalidResponse() if (body?.code !== 200 || !items) throw this.invalidResponse()
const datasets = items.filter((item) => asRecord(item)?.type === 'dataset') const datasets = filter(items.filter((item) => ['dataset', 'folder'].includes(String(asRecord(item)?.type)))
.map((item) => this.catalogItem(item, '_id', 'intro')))
const offset = (page - 1) * pageSize
return { return {
items: datasets.slice(0, 100).map((item) => this.catalogItem(item, '_id', 'intro')), items: datasets.slice(offset, offset + pageSize),
total: finiteNumber(data?.total) ?? datasets.length, total: datasets.length,
hasMore: datasets.length > 100 hasMore: datasets.length > offset + pageSize
} }
} }
const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets') const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets')
url.searchParams.set('page', '1') url.searchParams.set('page', String(page))
url.searchParams.set('page_size', '100') url.searchParams.set('page_size', String(pageSize))
const body = asRecord(await this.request(url, { method: 'GET' }, signal)) const body = asRecord(await this.request(url, { method: 'GET' }, signal))
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse() if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
return { return {
items: body.data.slice(0, 100).map((item) => { items: filter(body.data.slice(0, pageSize).map((item) => this.catalogItem(item))),
const record = asRecord(item)
const graphFinishedAt = record?.graphrag_task_finish_at
return {
...this.catalogItem(item),
graphEnabled:
(typeof graphFinishedAt === 'number' && graphFinishedAt > 0) ||
(typeof graphFinishedAt === 'string' && graphFinishedAt.length > 0)
}
}),
total: finiteNumber(body.total_datasets), total: finiteNumber(body.total_datasets),
hasMore: (finiteNumber(body.total_datasets) ?? 0) > 100 hasMore: finiteNumber(body.total_datasets) === undefined
? body.data.length >= pageSize
: Number(body.total_datasets) > page * pageSize
} }
} }
async getKnowledgeBase(remoteKnowledgeBaseId: string, signal?: AbortSignal): Promise<ExternalKnowledgeCatalogItem> {
const id = remoteKnowledgeBaseId.trim()
if (!id) throw new TypeError('Remote knowledge base ID is required')
const url = endpoint(this.normalizedBaseUrl, this.provider === 'dify'
? `/v1/datasets/${encodeURIComponent(id)}`
: this.provider === 'fastgpt' ? '/core/dataset/detail' : '/api/v1/datasets')
if (this.provider !== 'dify') url.searchParams.set('id', id)
const body = asRecord(await this.request(url, { method: 'GET' }, signal))
let item: ExternalKnowledgeCatalogItem
if (this.provider === 'dify') item = this.catalogItem(body)
else if (this.provider === 'fastgpt') {
if (body?.code !== 200) throw this.invalidResponse()
item = this.catalogItem(body.data, '_id', 'intro')
} else {
if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse()
const match = body.data.find((value) => asRecord(value)?.id === id)
if (!match) throw errorForStatus(404)
item = this.catalogItem(match)
}
if (item.id !== id) throw this.invalidResponse()
return item
}
async retrieve( async retrieve(
remoteKnowledgeBaseId: string, remoteKnowledgeBaseId: string,
query: string, query: string,
config: ExternalKnowledgeRetrievalConfig, config: ExternalKnowledgeRetrievalConfig,
signal?: AbortSignal signal?: AbortSignal
): Promise<ExternalKnowledgeRetrievalResult[]> { ): Promise<ExternalKnowledgeRetrievalResult[]> {
config = externalKnowledgeProviderConfigSchema.parse(config)
if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client') if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client')
const remoteId = remoteKnowledgeBaseId.trim() const remoteId = remoteKnowledgeBaseId.trim()
const question = query.trim() const question = query.trim()
if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required') if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required')
if (this.provider === 'dify') { if (this.provider === 'dify' && config.provider === 'dify') {
if (question.length > 250) throw new RangeError('Dify queries cannot exceed 250 characters') if (!config.useDatasetDefaults && config.retrievalModel.reranking_mode === 'weighted_score' && config.retrievalModel.weights) {
const weights = config.retrievalModel.weights
if (Math.abs(weights.vector_setting.vector_weight + weights.keyword_setting.keyword_weight - 1) > 1e-9) {
throw new TypeError('Dify retrieval weights must sum to one')
}
}
if (Array.from(question).length > 250) throw new RangeError('Dify queries cannot exceed 250 characters')
const body = asRecord(await this.request( const body = asRecord(await this.request(
endpoint(this.normalizedBaseUrl, `/v1/datasets/${encodeURIComponent(remoteId)}/retrieve`), endpoint(this.normalizedBaseUrl, `/v1/datasets/${encodeURIComponent(remoteId)}/retrieve`),
{ method: 'POST', body: JSON.stringify({ query: question }) }, { method: 'POST', body: JSON.stringify({ query: question, ...(!config.useDatasetDefaults ? { retrieval_model: config.retrievalModel } : {}) }) },
signal signal
)) ))
if (!Array.isArray(body?.records)) throw this.invalidResponse() if (!Array.isArray(body?.records)) throw this.invalidResponse()
@@ -267,8 +278,10 @@ export class ExternalKnowledgeClient {
signal signal
)) ))
const data = asRecord(body?.data) const data = asRecord(body?.data)
if (body?.code !== 200 || !Array.isArray(data?.list)) throw this.invalidResponse() // The official legacy manual documents data[]; current OpenAPI uses data.list[].
return data.list.slice(0, 20).map((item) => this.fastGptResult(item)) const items = Array.isArray(body?.data) ? body.data : data?.list
if (body?.code !== 200 || !Array.isArray(items)) throw this.invalidResponse()
return items.slice(0, 20).map((item) => this.fastGptResult(item))
} }
if (this.provider === 'ragflow' && config.provider === 'ragflow') { if (this.provider === 'ragflow' && config.provider === 'ragflow') {
@@ -299,12 +312,13 @@ export class ExternalKnowledgeClient {
} }
private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise<unknown> { private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise<unknown> {
const timeoutSignal = AbortSignal.timeout(this.timeoutMs)
const effectiveSignal = signal const effectiveSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) ? AbortSignal.any([signal, timeoutSignal])
: AbortSignal.timeout(this.timeoutMs) : timeoutSignal
let response: Response
try { try {
response = await this.fetcher(url, { effectiveSignal.throwIfAborted()
const response = await this.fetcher(url, {
...init, ...init,
redirect: 'error', redirect: 'error',
signal: effectiveSignal, signal: effectiveSignal,
@@ -314,20 +328,40 @@ export class ExternalKnowledgeClient {
...(init.body ? { 'Content-Type': 'application/json' } : {}) ...(init.body ? { 'Content-Type': 'application/json' } : {})
} }
}) })
if (effectiveSignal.aborted || !response.ok) {
void response.body?.cancel().catch(() => {})
effectiveSignal.throwIfAborted()
throw errorForStatus(response.status)
}
return await readBoundedJson(response, this.maximumResponseBytes, effectiveSignal)
} catch (error) { } catch (error) {
const code = effectiveSignal.aborted ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_NETWORK' if (effectiveSignal.aborted) {
throw new ExternalKnowledgeError(code, code === 'EXTERNAL_KB_TIMEOUT' ? 'External knowledge request timed out' : 'External knowledge service is unreachable', { cause: error }) const timedOut = effectiveSignal.reason === timeoutSignal.reason && timeoutSignal.aborted
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
}
if (error instanceof ExternalKnowledgeError) throw error
throw new ExternalKnowledgeError('EXTERNAL_KB_NETWORK', 'External knowledge service is unreachable')
} }
if (!response.ok) throw errorForStatus(response.status)
return readBoundedJson(response, this.maximumResponseBytes)
} }
private catalogItem(value: unknown, idKey = 'id', descriptionKey = 'description'): ExternalKnowledgeCatalogItem { private catalogItem(value: unknown, idKey = 'id', descriptionKey = 'description'): ExternalKnowledgeCatalogItem {
const record = asRecord(value) const record = asRecord(value)
const id = boundedString(record?.[idKey], 128) const id = boundedString(record?.[idKey], 512)
const name = boundedString(record?.name, 512) const name = boundedString(record?.name, 512)
if (!id || !name) throw this.invalidResponse() if (!id || !name) throw this.invalidResponse()
return { id, name, description: boundedString(record?.[descriptionKey], 2_000) } const item: ExternalKnowledgeCatalogItem = { id, name, description: boundedString(record?.[descriptionKey], 2_000), kind: 'dataset' }
if (this.provider === 'fastgpt') {
if (record?.type !== 'dataset' && record?.type !== 'folder') throw this.invalidResponse()
item.kind = record.type
}
if (this.provider === 'ragflow') {
const finished = record?.graphrag_task_finish_at
item.graphEnabled = typeof finished === 'number' ? Number.isFinite(finished) && finished > 0
: typeof finished === 'string' && (Number.isFinite(Number(finished)) ? Number(finished) > 0 : Date.parse(finished) > 0)
item.knowledgeCompilationEnabled = !!boundedString(record?.compilation_template_group_id, 512)
}
return item
} }
private difyResult(value: unknown): ExternalKnowledgeRetrievalResult { private difyResult(value: unknown): ExternalKnowledgeRetrievalResult {
@@ -341,7 +375,7 @@ export class ExternalKnowledgeClient {
documentTitle: title, documentTitle: title,
sourceDisplayName: title, sourceDisplayName: title,
snippet, snippet,
providerScore: finiteNumber(record?.score), providerScore: this.resultScore(record?.score),
remoteDocumentId: boundedString(document?.id, 128), remoteDocumentId: boundedString(document?.id, 128),
remoteChunkId: boundedString(segment?.id, 128), remoteChunkId: boundedString(segment?.id, 128),
location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}` location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}`
@@ -351,15 +385,26 @@ export class ExternalKnowledgeClient {
private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult { private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult {
const record = asRecord(value) const record = asRecord(value)
const question = boundedString(record?.q, 8_000) const question = boundedString(record?.q, 8_000)
if (!question) throw this.invalidResponse()
const answer = boundedString(record?.a, 8_000) const answer = boundedString(record?.a, 8_000)
const snippet = [question, answer].filter(Boolean).join('\n') const snippet = [question, answer].filter(Boolean).join('\n')
if (!snippet) throw this.invalidResponse() if (!snippet) throw this.invalidResponse()
const title = boundedString(record?.sourceName, 512) ?? 'FastGPT document' const title = boundedString(record?.sourceName, 512) ?? 'FastGPT document'
// Preserve channel semantics and provider order; these are not one comparable total score.
const providerScores = Array.isArray(record?.score) ? record.score.slice(0, 20).map((value) => {
const score = asRecord(value)
const type = boundedString(score?.type, 128)
const number = finiteNumber(score?.value)
const index = finiteNumber(score?.index)
if (!type || number === undefined || (score?.index !== undefined && (index === undefined || !Number.isInteger(index)))) throw this.invalidResponse()
return { type, value: number, ...(index === undefined ? {} : { index }) }
}) : undefined
return { return {
documentTitle: title, documentTitle: title,
sourceDisplayName: title, sourceDisplayName: title,
snippet: snippet.slice(0, 8_000), snippet: snippet.slice(0, 8_000),
providerScore: finiteNumber(record?.score), providerScore: providerScores ? undefined : this.resultScore(record?.score),
...(providerScores ? { providerScores } : {}),
remoteDocumentId: boundedString(record?.sourceId, 128), remoteDocumentId: boundedString(record?.sourceId, 128),
remoteChunkId: boundedString(record?.id, 128), remoteChunkId: boundedString(record?.id, 128),
location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}` location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}`
@@ -376,13 +421,20 @@ export class ExternalKnowledgeClient {
documentTitle: title, documentTitle: title,
sourceDisplayName: title, sourceDisplayName: title,
snippet, snippet,
providerScore: finiteNumber(record?.similarity), providerScore: this.resultScore(record?.similarity),
remoteDocumentId: boundedString(record?.document_id, 128), remoteDocumentId: boundedString(record?.document_id, 128),
remoteChunkId: boundedString(record?.id, 128), remoteChunkId: boundedString(record?.id, 128),
location: positions ? JSON.stringify(positions).slice(0, 8_192) : undefined location: positions ? JSON.stringify(positions).slice(0, 8_192) : undefined
} }
} }
private resultScore(value: unknown): number | undefined {
if (value === undefined || value === null) return undefined
const score = finiteNumber(value)
if (score === undefined) throw this.invalidResponse()
return score
}
private invalidResponse(): ExternalKnowledgeError { private invalidResponse(): ExternalKnowledgeError {
return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API') return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API')
} }
@@ -0,0 +1,230 @@
import { createServer, type Server } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, expect, it, vi } from 'vitest'
import { KnowledgeService } from '../knowledge-service'
import { KnowledgeMcpGateway } from '../../agent/knowledge-mcp-gateway'
import { knowledgeReferenceKey, toKnowledgeReference } from '../../../shared/knowledge-reference'
import { conversationMessageSchema } from '../../../shared/assistant-contracts'
const services: KnowledgeService[] = []
const directories: string[] = []
const servers: Server[] = []
afterEach(async () => {
await Promise.all(services.splice(0).map(service => service.dispose()))
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => resolve()))))
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
})
async function setup(fetcher?: typeof fetch) {
const directory = await mkdtemp(join(tmpdir(), 'external-kb-'))
directories.push(directory)
const path = join(directory, 'knowledge.db')
const cipher = { isAvailable: () => true, encrypt: (value: string) => Buffer.from(value), decrypt: (value: Buffer) => value.toString() }
const options = { databasePath: path, managedRoot: join(directory, 'managed'), externalFetcher: fetcher, credentialCipher: cipher }
const service = new KnowledgeService(options)
await service.initialize()
services.push(service)
return { service, path, cipher, options }
}
const commonConfig = { resultLimit: 20, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 }
const providerConfig = { provider: 'dify' as const, useDatasetDefaults: true as const }
const records = (count = 1, size = 16) => ({ records: Array.from({ length: count }, (_, index) => ({
score: 0.8, segment: { id: `chunk-${index}`, content: 'e'.repeat(size), document: { id: 'remote-doc', name: 'Handbook' } }
})) })
function instance(service: KnowledgeService, baseUrl = 'https://kb.example/v1') {
return service.external.saveInstance({ name: 'Dify', provider: 'dify', baseUrl, enabled: true, credential: { action: 'replace', value: 'test-key' } })
}
function bindingInput(instanceId: string, remoteKnowledgeBaseId = 'dataset') {
return { instanceId, remoteKnowledgeBaseId, name: 'Handbook', remoteName: 'Remote handbook', testQuery: 'policy', commonConfig, providerConfig }
}
function retrievalInput(instanceId: string) {
return { instanceId, remoteKnowledgeBaseId: 'dataset', testQuery: 'policy', commonConfig, providerConfig }
}
it('routes the real HTTP client through directory, detail, binding and scoped MCP without remote writes', async () => {
const requests: string[] = []
const server = createServer((request, response) => {
requests.push(`${request.method} ${request.url}`)
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify(request.url?.includes('/retrieve') ? records() : request.url?.includes('?')
? { data: [{ id: 'dataset', name: 'Handbook' }], has_more: false }
: { id: 'dataset', name: 'Handbook' }))
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const { service } = await setup()
const saved = instance(service, `http://127.0.0.1:${address.port}/v1`)
expect(saved).not.toHaveProperty('credential')
expect((await service.external.listCatalog({ instanceId: saved.id })).items).toHaveLength(1)
expect(await service.external.getCatalog({ instanceId: saved.id, remoteKnowledgeBaseId: 'dataset' })).toMatchObject({ id: 'dataset' })
const binding = await service.external.saveBinding(bindingInput(saved.id))
const gateway = new KnowledgeMcpGateway(service)
const token = gateway.grant('request', [binding.knowledgeBaseId], new AbortController().signal)!
try {
const references = await gateway.search(token, { query: 'policy' })
expect(references).toHaveLength(1)
expect(references[0]).toMatchObject({ external: { remoteDocumentId: 'remote-doc', remoteChunkId: 'chunk-0', providerScore: 0.8 } })
expect(references[0]?.documentId).toBeUndefined()
expect(service.database.listDocuments(binding.knowledgeBaseId)).toEqual([])
expect(requests).toEqual(['GET /v1/datasets?page=1&limit=100', 'GET /v1/datasets/dataset', 'POST /v1/datasets/dataset/retrieve', 'POST /v1/datasets/dataset/retrieve'])
} finally { await gateway.dispose() }
})
it('keeps actual failures, bounds mixed retrieval to 48k, and rejects local mutations', async () => {
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records(20, 8000)))
const { service } = await setup(fetcher)
const saved = instance(service)
const first = await service.external.saveBinding(bindingInput(saved.id))
const second = await service.external.saveBinding(bindingInput(saved.id, 'second'))
const results = await service.retrieveMany([first.knowledgeBaseId, second.knowledgeBaseId], 'policy')
expect(results.flatMap(item => item.response.results).reduce((sum, result) => sum + result.snippet.length, 0)).toBe(48000)
fetcher.mockResolvedValue(new Response('', { status: 401 }))
const failed = await service.retrieveMany([first.knowledgeBaseId], 'policy')
expect(failed[0]?.response.diagnostics.failure).toBe('EXTERNAL_KB_AUTH')
await expect(service.importPaths(first.knowledgeBaseId, [])).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
await expect(service.rebuildLibrary({ knowledgeBaseId: first.knowledgeBaseId })).rejects.toThrow('EXTERNAL_KB_READ_ONLY')
expect(() => service.updateSettings({ knowledgeBaseId: first.knowledgeBaseId, retrieval: service.database.getKnowledgeBase(first.knowledgeBaseId)!.retrievalSettings })).toThrow('EXTERNAL_KB_READ_ONLY')
expect(() => service.database.updateKnowledgeBase(first.knowledgeBaseId, { graphEnabled: true })).toThrow('EXTERNAL_KB_READ_ONLY')
})
it('rolls metadata back with binding failures and rejects concurrent duplicate creates', async () => {
const { service, path } = await setup(async () => Response.json(records()))
const saved = instance(service)
const first = await service.external.saveBinding(bindingInput(saved.id))
const inspection = new DatabaseSync(path)
inspection.exec("CREATE TRIGGER reject_binding BEFORE UPDATE ON external_knowledge_bindings BEGIN SELECT RAISE(ABORT, 'binding failed'); END")
try {
await expect(service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: first.knowledgeBaseId, name: 'Changed' })).rejects.toThrow('binding failed')
expect(service.database.getKnowledgeBase(first.knowledgeBaseId)?.name).toBe('Handbook')
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ user_version: 12 })
} finally { inspection.close() }
const outcomes = await Promise.allSettled([service.external.saveBinding(bindingInput(saved.id, 'new')), service.external.saveBinding(bindingInput(saved.id, 'new'))])
expect(outcomes.filter(item => item.status === 'fulfilled')).toHaveLength(1)
expect(service.database.listKnowledgeBases()).toHaveLength(2)
})
it('cancels pending probes on disable and prevents shutdown writeback', async () => {
let release!: (value: Response) => void
const fetcher = vi.fn<typeof fetch>(() => new Promise<Response>(resolve => { release = resolve }))
const { service } = await setup(fetcher)
const saved = instance(service)
const probe = service.external.testInstance(saved.id)
service.external.setEnabled(saved.id, false)
release(Response.json({ data: [], has_more: false }))
await expect(probe).rejects.toThrow('EXTERNAL_KB_CONFIG_CHANGED')
expect(service.external.listInstances()[0]?.enabled).toBe(false)
service.external.setEnabled(saved.id, true)
const pending = service.external.saveBinding(bindingInput(saved.id))
const rejected = expect(pending).rejects.toThrow()
await service.dispose()
services.splice(services.indexOf(service), 1)
release(Response.json(records()))
await rejected
expect(() => service.external.listInstances()).toThrow('EXTERNAL_KB_CANCELLED')
})
it('persists external locators without local IDs and distinguishes remote chunks', async () => {
const { service } = await setup(async () => Response.json(records(2)))
const binding = await service.external.saveBinding(bindingInput(instance(service).id))
const response = await service.retrieve({ knowledgeBaseId: binding.knowledgeBaseId, query: 'policy' })
const references = response.results.map(result => toKnowledgeReference(result, 'Handbook'))
expect(knowledgeReferenceKey(references[0]!)).not.toBe(knowledgeReferenceKey(references[1]!))
const message = conversationMessageSchema.parse({ id: crypto.randomUUID(), role: 'assistant', content: '', createdAt: 1, state: 'complete', sourceReferences: references })
expect(message.sourceReferences?.[0]?.external).toEqual(references[0]?.external)
})
it('reports unusable credentials and preserves the stored key when encryption fails', async () => {
const fetcher = vi.fn<typeof fetch>(async () => Response.json(records()))
const { service, cipher } = await setup(fetcher)
const saved = instance(service)
const before = service.database.externalStore.getInstance(saved.id)
const decrypt = vi.spyOn(cipher, 'decrypt').mockImplementation(() => { throw new Error('OS key unavailable') })
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
decrypt.mockReturnValue(JSON.stringify(' '))
expect(service.external.listInstances()[0]?.credentialStatus).toBe('unavailable')
await expect(service.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
expect(fetcher).not.toHaveBeenCalled()
decrypt.mockRestore()
vi.spyOn(cipher, 'encrypt').mockImplementation(() => { throw new Error('OS encryption failed') })
expect(() => service.external.saveInstance({ id: saved.id, name: 'Changed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true,
credential: { action: 'replace', value: 'new-key' } })).toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
expect(service.database.externalStore.getInstance(saved.id)).toEqual(before)
expect(service.external.listInstances()[0]?.credentialStatus).toBe('configured')
})
it('persists keep and clear credential mutations and normalized binding updates across restart', async () => {
const { service, options } = await setup(async () => Response.json(records()))
const saved = instance(service)
const binding = await service.external.saveBinding(bindingInput(saved.id))
const credential = service.database.externalStore.getInstance(saved.id)?.credential
service.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'keep' } })
const updated = await service.external.saveBinding({ ...bindingInput(saved.id), knowledgeBaseId: ` ${binding.knowledgeBaseId} `, name: 'Updated' })
expect(updated.knowledgeBaseId).toBe(binding.knowledgeBaseId)
expect(service.database.externalStore.getInstance(saved.id)?.credential).toEqual(credential)
await service.dispose()
services.splice(services.indexOf(service), 1)
const reopened = new KnowledgeService(options)
services.push(reopened)
await reopened.initialize()
expect(reopened.external.listInstances()[0]).toMatchObject({ name: 'Renamed', credentialStatus: 'configured', bindingCount: 1 })
expect(reopened.database.getKnowledgeBase(binding.knowledgeBaseId)?.name).toBe('Updated')
expect(reopened.database.externalStore.listBindings()).toEqual([updated])
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).resolves.toMatchObject({ results: [expect.anything()] })
reopened.external.saveInstance({ id: saved.id, name: 'Renamed', provider: saved.provider, baseUrl: saved.baseUrl, enabled: true, credential: { action: 'clear' } })
expect(reopened.external.listInstances()[0]).toMatchObject({ credentialStatus: 'missing', bindingCount: 1 })
const inspection = new DatabaseSync(options.databasePath)
try {
const row = inspection.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(saved.id)!
expect(JSON.parse(String(row.value_json))).not.toHaveProperty('credential')
} finally { inspection.close() }
await expect(reopened.external.testRetrieval(retrievalInput(saved.id))).rejects.toThrow('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
})
it('does not invalidate retrieval or binding verification when a concurrent probe persists its result', async () => {
const releases: ((response: Response) => void)[] = []
const { service } = await setup(() => new Promise<Response>(resolve => releases.push(resolve)))
const saved = instance(service)
const binding = service.external.saveBinding(bindingInput(saved.id))
const retrieval = service.external.testRetrieval(retrievalInput(saved.id))
const probe = service.external.testInstance(saved.id)
releases[2]!(Response.json({ data: [], has_more: false }))
await expect(probe).resolves.toMatchObject({ probeStatus: 'catalog-ready' })
releases[0]!(Response.json(records()))
releases[1]!(Response.json(records()))
await expect(binding).resolves.toMatchObject({ instanceId: saved.id })
await expect(retrieval).resolves.toMatchObject({ results: [expect.anything()] })
expect(service.external.listInstances()[0]?.probeStatus).toBe('catalog-ready')
})
it('normalizes pre-request cancellation and persists network probe failures as unreachable', async () => {
const fetcher = vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') })
const { service } = await setup(fetcher)
const saved = instance(service)
await expect(service.external.testRetrieval(retrievalInput(saved.id), AbortSignal.abort())).rejects.toMatchObject({ code: 'EXTERNAL_KB_CANCELLED' })
expect(fetcher).not.toHaveBeenCalled()
await expect(service.external.testInstance(saved.id)).resolves.toMatchObject({ probeStatus: 'unreachable', lastErrorCode: 'EXTERNAL_KB_NETWORK' })
})
it('includes the RAGFlow capability check in the retrieval timeout budget', async () => {
const fetcher = vi.fn<typeof fetch>(async url => {
await new Promise(resolve => setTimeout(resolve, 600))
return Response.json(String(url).includes('/datasets')
? { code: 0, data: [{ id: 'dataset', name: 'Graph', graphrag_task_finish_at: 1 }] }
: { code: 0, data: { chunks: [] } })
})
const { service } = await setup(fetcher)
const saved = service.external.saveInstance({ name: 'RAGFlow', provider: 'ragflow', baseUrl: 'https://kb.example', enabled: true, credential: { action: 'replace', value: 'test-key' } })
await expect(service.external.testRetrieval({ ...retrievalInput(saved.id), providerConfig: {
provider: 'ragflow', similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 10, useKg: true, includeKnowledgeCompilation: false
} })).rejects.toMatchObject({ code: 'EXTERNAL_KB_TIMEOUT' })
expect(fetcher).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,154 @@
import { randomUUID } from 'node:crypto'
import { externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingTestInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeInstanceSaveInputSchema, type ExternalKnowledgeBinding, type ExternalKnowledgeBindingSaveInput, type ExternalKnowledgeBindingTestInput, type ExternalKnowledgeInstanceSaveInput, type ExternalKnowledgeInstanceSummary, type ExternalKnowledgeTestResult } from '../../../shared/external-knowledge-contracts'
import { decryptSettingsCredential, encryptSettingsCredential, type SettingsCredentialCipher } from '../../settings-credential-cipher'
import type { KnowledgeDatabase } from '../knowledge-database'
import { ExternalKnowledgeClient, ExternalKnowledgeError } from './external-knowledge-client'
import type { StoredExternalInstance } from './external-knowledge-store'
// Probe results are persisted metadata, not a change to an in-flight request's configuration.
function instanceConfiguration(value: StoredExternalInstance): string {
return JSON.stringify([value.id, value.name, value.provider, value.baseUrl, value.enabled, value.credential])
}
export class ExternalKnowledgeService {
private disposed = false
private readonly controllers = new Map<AbortController,string>()
constructor(private readonly database: KnowledgeDatabase, private readonly cipher?: SettingsCredentialCipher, private readonly fetcher?: typeof fetch) {}
dispose(): void { this.disposed = true; for (const controller of this.controllers.keys()) controller.abort(); this.controllers.clear() }
private checkActive(): void { if (this.disposed) throw new Error('EXTERNAL_KB_CANCELLED') }
private cancelInstance(id: string): void {
for (const [controller, instanceId] of this.controllers) if (instanceId === id) controller.abort()
}
private instance(id:string): StoredExternalInstance {
this.checkActive()
const value=this.database.externalStore.getInstance(id)
if (!value) throw new Error('EXTERNAL_KB_NOT_FOUND')
return value
}
listInstances(): ExternalKnowledgeInstanceSummary[] {
this.checkActive()
const bindings=this.database.externalStore.listBindings()
return this.database.externalStore.listInstances().map(({credential,...value})=>{
let credentialStatus: ExternalKnowledgeInstanceSummary['credentialStatus'] = 'missing'
if (credential) {
try { this.credential({ ...value, credential }); credentialStatus = 'configured' }
catch { credentialStatus = 'unavailable' }
}
return {...value,credentialStatus,bindingCount:bindings.filter(binding=>binding.instanceId===value.id).length}
})
}
private credential(instance: StoredExternalInstance): string {
try {
if (!instance.credential || !this.cipher?.isAvailable()) throw new Error()
const key = decryptSettingsCredential(this.cipher, instance.credential)
if (typeof key !== 'string' || !key.trim()) throw new Error()
return key
} catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
}
saveInstance(raw:ExternalKnowledgeInstanceSaveInput):ExternalKnowledgeInstanceSummary {
this.checkActive()
const input=externalKnowledgeInstanceSaveInputSchema.parse(raw)
const previous=input.id ? this.instance(input.id):undefined
if (previous && previous.provider!==input.provider && this.database.externalStore.listBindings().some(binding=>binding.instanceId===previous.id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
let credential=previous?.credential
if (input.credential.action==='clear') credential=undefined
if (input.credential.action==='replace') {
if (!this.cipher?.isAvailable()) throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE')
try { credential=encryptSettingsCredential(this.cipher,input.credential.value) }
catch { throw new Error('EXTERNAL_KB_CREDENTIAL_UNAVAILABLE') }
}
const value:StoredExternalInstance={id:previous?.id??randomUUID(),name:input.name,provider:input.provider,baseUrl:input.baseUrl.replace(/\/+$/,''),enabled:input.enabled,credential,probeStatus:'untested'}
this.database.externalStore.saveInstance(value)
this.cancelInstance(value.id)
return this.listInstances().find(item=>item.id===value.id)!
}
setEnabled(id:string,enabled:boolean):ExternalKnowledgeInstanceSummary {
const instance=this.instance(id)
this.database.externalStore.saveInstance({...instance,enabled})
if (!enabled) for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
return this.listInstances().find(item=>item.id===id)!
}
deleteInstance(id:string):void {
this.instance(id)
if(this.database.externalStore.listBindings().some(binding=>binding.instanceId===id)) throw new Error('EXTERNAL_KB_INSTANCE_IN_USE')
for(const [controller,instanceId] of this.controllers) if(instanceId===id) controller.abort()
this.database.externalStore.deleteInstance(id)
}
private async request<T>(id:string,timeout:number,signal:AbortSignal|undefined,operation:(client:ExternalKnowledgeClient,signal:AbortSignal)=>Promise<T>):Promise<T> {
const instance=this.instance(id)
if(!instance.enabled) throw new Error('EXTERNAL_KB_DISABLED')
const key = this.credential(instance)
const controller=new AbortController()
this.controllers.set(controller,id)
const timeoutSignal = AbortSignal.timeout(timeout)
const combined = AbortSignal.any([controller.signal, timeoutSignal, ...(signal ? [signal] : [])])
try {
combined.throwIfAborted()
const result = await operation(new ExternalKnowledgeClient({provider:instance.provider,baseUrl:instance.baseUrl,apiKey:key,timeoutMs:timeout,fetcher:this.fetcher}),combined)
combined.throwIfAborted()
return result
} catch (error) {
if (combined.aborted) {
const timedOut = timeoutSignal.aborted && combined.reason === timeoutSignal.reason
throw new ExternalKnowledgeError(timedOut ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_CANCELLED',
timedOut ? 'External knowledge request timed out' : 'External knowledge request was cancelled')
}
throw error
} finally {this.controllers.delete(controller)}
}
async testInstance(id:string):Promise<ExternalKnowledgeInstanceSummary> {
const instance=this.instance(id)
let probeStatus:StoredExternalInstance['probeStatus']='catalog-ready'
let lastErrorCode:string|undefined
try {await this.listCatalog({instanceId:id})} catch(error) {
lastErrorCode=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_/.test(error.message) ? error.message : 'EXTERNAL_KB_NETWORK'
probeStatus=lastErrorCode==='EXTERNAL_KB_FORBIDDEN'?'list-restricted':lastErrorCode==='EXTERNAL_KB_AUTH'?'auth-failed':lastErrorCode==='EXTERNAL_KB_NETWORK'||lastErrorCode==='EXTERNAL_KB_TIMEOUT'?'unreachable':'failed'
}
const current = this.instance(id)
if (instanceConfiguration(current) !== instanceConfiguration(instance)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
this.database.externalStore.saveInstance({...current,probeStatus,lastErrorCode,lastTestedAt:new Date().toISOString()})
return this.listInstances().find(item=>item.id===id)!
}
listCatalog(input:{instanceId:string;page?:number;pageSize?:number;parentId?:string|null;search?:string},signal?:AbortSignal) {
return this.request(input.instanceId,15000,signal,(client,current)=>client.listKnowledgeBases(current,input))
}
getCatalog(input:{instanceId:string;remoteKnowledgeBaseId:string},signal?:AbortSignal) {
return this.request(input.instanceId,15000,signal,(client,current)=>client.getKnowledgeBase(input.remoteKnowledgeBaseId,current))
}
async testRetrieval(raw:ExternalKnowledgeBindingTestInput,signal?:AbortSignal):Promise<ExternalKnowledgeTestResult> {
const input=externalKnowledgeBindingTestInputSchema.parse(raw)
const instance=this.instance(input.instanceId)
if(instance.provider!==input.providerConfig.provider) throw new Error('EXTERNAL_KB_CONFIG_INVALID')
const started=Date.now()
const results=await this.request(input.instanceId,input.commonConfig.requestTimeoutMs,signal,async (client,current)=>{
if(input.providerConfig.provider==='ragflow' && (input.providerConfig.useKg||input.providerConfig.includeKnowledgeCompilation)) {
const detail=await client.getKnowledgeBase(input.remoteKnowledgeBaseId,current)
if((input.providerConfig.useKg&&!detail.graphEnabled)||(input.providerConfig.includeKnowledgeCompilation&&!detail.knowledgeCompilationEnabled)) throw new Error('EXTERNAL_KB_CAPABILITY_UNAVAILABLE')
}
return client.retrieve(input.remoteKnowledgeBaseId,input.testQuery,input.providerConfig,current)
})
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
let remaining = 48_000
const bounded = results.slice(0, input.commonConfig.resultLimit).map(item => {
const snippet = item.snippet.slice(0, Math.min(remaining, input.commonConfig.maxSnippetCharacters))
remaining -= snippet.length
return { ...item, snippet }
}).filter(item => item.snippet.length > 0)
return {results:bounded,durationMs:Date.now()-started}
}
async saveBinding(raw:ExternalKnowledgeBindingSaveInput & {knowledgeBaseId?:string}):Promise<ExternalKnowledgeBinding> {
this.checkActive()
const input=raw.knowledgeBaseId !== undefined ? externalKnowledgeBindingUpdateInputSchema.parse(raw):externalKnowledgeBindingSaveInputSchema.parse(raw)
const knowledgeBaseId = 'knowledgeBaseId' in input ? input.knowledgeBaseId : undefined
const existing=knowledgeBaseId ? this.database.externalStore.listBindings().find(item=>item.knowledgeBaseId===knowledgeBaseId):undefined
if(knowledgeBaseId&&!existing) throw new Error('EXTERNAL_KB_NOT_FOUND')
if(this.database.externalStore.listBindings().some(item=>item.instanceId===input.instanceId&&item.remoteKnowledgeBaseId===input.remoteKnowledgeBaseId&&item.knowledgeBaseId!==existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
const instance = this.instance(input.instanceId)
await this.testRetrieval({instanceId:input.instanceId,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,commonConfig:input.commonConfig,providerConfig:input.providerConfig,testQuery:input.testQuery})
if (instanceConfiguration(instance) !== instanceConfiguration(this.instance(input.instanceId))) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
const bindings = this.database.externalStore.listBindings()
if (existing && JSON.stringify(bindings.find(item => item.knowledgeBaseId === existing.knowledgeBaseId)) !== JSON.stringify(existing)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
if (bindings.some(item => item.instanceId === input.instanceId && item.remoteKnowledgeBaseId === input.remoteKnowledgeBaseId && item.knowledgeBaseId !== existing?.knowledgeBaseId)) throw new Error('EXTERNAL_KB_DUPLICATE_BINDING')
return this.database.saveExternalBinding({instanceId:input.instanceId,provider:input.providerConfig.provider,remoteKnowledgeBaseId:input.remoteKnowledgeBaseId,remoteName:input.remoteName,commonConfig:input.commonConfig,providerConfig:input.providerConfig,lastVerifiedAt:new Date().toISOString()}, {name:input.name,description:input.description,knowledgeBaseId:existing?.knowledgeBaseId})
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { DatabaseSync } from 'node:sqlite'
import type { ExternalKnowledgeBinding, ExternalKnowledgeInstanceSummary } from '../../../shared/external-knowledge-contracts'
import type { EncryptedSettingsCredential } from '../../settings-credential-cipher'
export type StoredExternalInstance = Omit<ExternalKnowledgeInstanceSummary, 'credentialStatus' | 'bindingCount'> & {credential?:EncryptedSettingsCredential}
export class ExternalKnowledgeStore {
constructor(private readonly database: DatabaseSync) {}
getInstance(id: string): StoredExternalInstance | undefined {
const row = this.database.prepare('SELECT value_json FROM external_knowledge_instances WHERE id=?').get(id)
return row ? JSON.parse(String(row.value_json)) as StoredExternalInstance : undefined
}
listInstances(): StoredExternalInstance[] {
return this.database.prepare('SELECT value_json FROM external_knowledge_instances ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as StoredExternalInstance)
}
saveInstance(value: StoredExternalInstance): void {
this.database.prepare('INSERT INTO external_knowledge_instances(id,value_json) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET value_json=excluded.value_json').run(value.id, JSON.stringify(value))
}
deleteInstance(id: string): void {
this.database.prepare('DELETE FROM external_knowledge_instances WHERE id=?').run(id)
}
listBindings(): ExternalKnowledgeBinding[] {
return this.database.prepare('SELECT value_json FROM external_knowledge_bindings ORDER BY rowid').all().map(row => JSON.parse(String(row.value_json)) as ExternalKnowledgeBinding)
}
saveBinding(value: ExternalKnowledgeBinding): void {
this.database.prepare('INSERT INTO external_knowledge_bindings(knowledge_base_id,instance_id,remote_id,value_json) VALUES (?,?,?,?) ON CONFLICT(knowledge_base_id) DO UPDATE SET instance_id=excluded.instance_id,remote_id=excluded.remote_id,value_json=excluded.value_json').run(value.knowledgeBaseId,value.instanceId,value.remoteKnowledgeBaseId,JSON.stringify(value))
}
}
+12 -11
View File
@@ -86,7 +86,7 @@ describe('KnowledgeDatabase', () => {
const inspection = new DatabaseSync(path) const inspection = new DatabaseSync(path)
expect( expect(
inspection.prepare('PRAGMA user_version').get() inspection.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 11 }) ).toEqual({ user_version: 12 })
expect( expect(
inspection inspection
.prepare('SELECT version FROM schema_migrations ORDER BY version') .prepare('SELECT version FROM schema_migrations ORDER BY version')
@@ -102,7 +102,8 @@ describe('KnowledgeDatabase', () => {
{ version: 8 }, { version: 8 },
{ version: 9 }, { version: 9 },
{ version: 10 }, { version: 10 },
{ version: 11 } { version: 11 },
{ version: 12 }
]) ])
inspection.close() inspection.close()
@@ -143,14 +144,14 @@ describe('KnowledgeDatabase', () => {
expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1) expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1)
const inspection = new DatabaseSync(path) const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: 11 user_version: 12
}) })
inspection.close() inspection.close()
}) })
it.each([ it.each([
['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true], ['migration version', 'INSERT INTO schema_migrations VALUES (13, ?)', true],
['user version', 'PRAGMA user_version = 12', false] ['user version', 'PRAGMA user_version = 13', false]
])('rejects a future %s without downgrading it', async ( ])('rejects a future %s without downgrading it', async (
_label, _label,
statement, statement,
@@ -168,18 +169,18 @@ describe('KnowledgeDatabase', () => {
const unsupported = new KnowledgeDatabase(path) const unsupported = new KnowledgeDatabase(path)
expect(() => unsupported.initialize()).toThrow( expect(() => unsupported.initialize()).toThrow(
'newer than supported version 11' 'newer than supported version 12'
) )
const inspection = new DatabaseSync(path) const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: hasParameter ? 11 : 12 user_version: hasParameter ? 12 : 13
}) })
expect( expect(
inspection inspection
.prepare('SELECT MAX(version) AS version FROM schema_migrations') .prepare('SELECT MAX(version) AS version FROM schema_migrations')
.get() .get()
).toEqual({ version: hasParameter ? 12 : 11 }) ).toEqual({ version: hasParameter ? 13 : 12 })
inspection.close() inspection.close()
}) })
@@ -319,7 +320,7 @@ describe('KnowledgeDatabase', () => {
DROP TABLE chunk_embeddings; DROP TABLE chunk_embeddings;
DROP TABLE knowledge_tasks; DROP TABLE knowledge_tasks;
DELETE FROM schema_migrations DELETE FROM schema_migrations
WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11); WHERE version > 1;
PRAGMA user_version = 1; PRAGMA user_version = 1;
`) `)
downgrade.close() downgrade.close()
@@ -329,7 +330,7 @@ describe('KnowledgeDatabase', () => {
upgraded.initialize() upgraded.initialize()
const inspection = new DatabaseSync(path) const inspection = new DatabaseSync(path)
expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ expect(inspection.prepare('PRAGMA user_version').get()).toEqual({
user_version: 11 user_version: 12
}) })
expect( expect(
inspection inspection
@@ -436,7 +437,7 @@ describe('KnowledgeDatabase', () => {
WHERE new.enabled = 1 AND new.role <> 'parent'; WHERE new.enabled = 1 AND new.role <> 'parent';
END; END;
INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild'); INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild');
DELETE FROM schema_migrations WHERE version IN (9, 10, 11); DELETE FROM schema_migrations WHERE version > 8;
PRAGMA user_version = 8; PRAGMA user_version = 8;
`) `)
downgrade downgrade
+36 -1
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from 'node:crypto' import { createHash, randomUUID } from 'node:crypto'
import { ExternalKnowledgeStore } from './external/external-knowledge-store'
import { DatabaseSync, type StatementSync } from 'node:sqlite' import { DatabaseSync, type StatementSync } from 'node:sqlite'
import { import {
embeddingIndexJobSchema, embeddingIndexJobSchema,
@@ -80,7 +81,7 @@ import type {
VectorSearchOptions VectorSearchOptions
} from './types' } from './types'
const DATABASE_VERSION = 11 const DATABASE_VERSION = 12
const MAX_ID_LENGTH = 128 const MAX_ID_LENGTH = 128
const MAX_NAME_LENGTH = 512 const MAX_NAME_LENGTH = 512
const MAX_LOCATION_LENGTH = 8192 const MAX_LOCATION_LENGTH = 8192
@@ -739,6 +740,7 @@ function mapKnowledgeTask(row: Row): KnowledgeTaskItem {
} }
export class KnowledgeDatabase { export class KnowledgeDatabase {
externalStore!: ExternalKnowledgeStore
private database?: DatabaseSync private database?: DatabaseSync
private readonly taskPrunedLibraries = new Set<string>() private readonly taskPrunedLibraries = new Set<string>()
private readonly taskPrunePending = new Set<string>() private readonly taskPrunePending = new Set<string>()
@@ -765,6 +767,7 @@ export class KnowledgeDatabase {
this.assertFts5(database) this.assertFts5(database)
this.migrate(database) this.migrate(database)
this.database = database this.database = database
this.externalStore = new ExternalKnowledgeStore(database)
this.interruptActiveKnowledgeTasks() this.interruptActiveKnowledgeTasks()
database database
.prepare('DELETE FROM embedding_rebuild_staging') .prepare('DELETE FROM embedding_rebuild_staging')
@@ -903,11 +906,30 @@ export class KnowledgeDatabase {
return row ? mapKnowledgeBase(row) : undefined return row ? mapKnowledgeBase(row) : undefined
} }
saveExternalBinding(
binding: Omit<import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding, 'knowledgeBaseId'>,
metadata: { name: string; description?: string; knowledgeBaseId?: string }
): import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding {
let saved!: import('../../shared/external-knowledge-contracts').ExternalKnowledgeBinding
this.transaction(this.requireDatabase(), () => {
const library = metadata.knowledgeBaseId
? this.updateKnowledgeBase(metadata.knowledgeBaseId, { name: metadata.name, description: metadata.description })
: this.createKnowledgeBase({ name: metadata.name, description: metadata.description, storageMode: 'reference', graphEnabled: false })
saved = { ...binding, knowledgeBaseId: library.id }
this.externalStore.saveBinding(saved)
})
return saved
}
updateKnowledgeBase( updateKnowledgeBase(
id: string, id: string,
input: UpdateKnowledgeBaseInput input: UpdateKnowledgeBaseInput
): KnowledgeBase { ): KnowledgeBase {
const current = this.requiredKnowledgeBase(id) const current = this.requiredKnowledgeBase(id)
if (this.externalStore.listBindings().some(item => item.knowledgeBaseId === id) &&
(input.graphEnabled !== undefined || input.graphStrategy !== undefined || input.storageMode !== undefined)) {
throw new Error('EXTERNAL_KB_READ_ONLY')
}
const name = const name =
input.name === undefined input.name === undefined
? current.name ? current.name
@@ -4587,6 +4609,19 @@ export class KnowledgeDatabase {
) )
.run(11, new Date().toISOString()) .run(11, new Date().toISOString())
} }
if (currentVersion < 12) {
database.exec(`CREATE TABLE IF NOT EXISTS external_knowledge_instances (
id TEXT PRIMARY KEY, value_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS external_knowledge_bindings (
knowledge_base_id TEXT PRIMARY KEY REFERENCES knowledge_bases(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
remote_id TEXT NOT NULL, value_json TEXT NOT NULL,
UNIQUE(instance_id, remote_id)
);`)
database.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)')
.run(12, new Date().toISOString())
}
database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`) database.exec(`PRAGMA user_version = ${DATABASE_VERSION}`)
database.exec('COMMIT') database.exec('COMMIT')
} catch (error) { } catch (error) {
@@ -1113,6 +1113,42 @@ describe('KnowledgeService', () => {
).toBe(true) ).toBe(true)
}) })
it('retains ranked evidence from each external library within the shared budget', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-knowledge-service-'))
temporaryDirectories.push(directory)
const service = new KnowledgeService({
databasePath: join(directory, 'knowledge.sqlite'),
managedRoot: join(directory, 'managed'),
credentialCipher: {
isAvailable: () => true,
encrypt: value => Buffer.from(value),
decrypt: value => value.toString()
},
externalFetcher: async () => Response.json({ records: Array.from({ length: 6 }, (_, index) => ({
segment: { id: `chunk-${index}`, content: 'e'.repeat(8000), document: { id: 'document', name: 'Handbook' } }
})) })
})
services.push(service)
await service.initialize()
const instance = service.external.saveInstance({
name: 'Test', provider: 'dify', baseUrl: 'https://kb.example', enabled: true,
credential: { action: 'replace', value: 'test-key' }
})
const bindings = []
for (const remoteKnowledgeBaseId of ['first', 'second']) {
bindings.push(await service.external.saveBinding({
instanceId: instance.id, remoteKnowledgeBaseId, name: remoteKnowledgeBaseId,
remoteName: remoteKnowledgeBaseId, testQuery: 'policy',
commonConfig: { resultLimit: 6, requestTimeoutMs: 1000, maxSnippetCharacters: 8000 },
providerConfig: { provider: 'dify', useDatasetDefaults: true }
}))
}
const outcomes = await service.retrieveMany(bindings.map(binding => binding.knowledgeBaseId), 'policy')
expect(outcomes.map(({ response }) => response.results.map(result => result.rank))).toEqual([[1, 2, 3], [1, 2, 3]])
expect(outcomes.map(({ response }) => response.context.characterCount)).toEqual([24000, 24000])
expect(outcomes.every(({ response }) => response.context.truncated)).toBe(true)
})
it('reranks multiple libraries concurrently while preserving input order', async () => { it('reranks multiple libraries concurrently while preserving input order', async () => {
let releaseReranks: (() => void) | undefined let releaseReranks: (() => void) | undefined
let startedReranks = 0 let startedReranks = 0
+70 -11
View File
@@ -1,3 +1,5 @@
import { ExternalKnowledgeService } from './external/external-knowledge-service'
import { ExternalKnowledgeError } from './external/external-knowledge-client'
import { import {
cp, cp,
lstat, lstat,
@@ -144,6 +146,8 @@ export type KnowledgeSnapshot = {
} }
export type KnowledgeServiceOptions = { export type KnowledgeServiceOptions = {
credentialCipher?: import('../settings-credential-cipher').SettingsCredentialCipher
externalFetcher?: typeof fetch
databasePath: string databasePath: string
managedRoot: string managedRoot: string
extractStructured?: ExtractStructured extractStructured?: ExtractStructured
@@ -208,6 +212,7 @@ function embedKnowledgeQuery(
} }
export class KnowledgeService { export class KnowledgeService {
readonly external: ExternalKnowledgeService
readonly database: KnowledgeDatabase readonly database: KnowledgeDatabase
private readonly managedRoot: string private readonly managedRoot: string
private readonly extractStructured?: ExtractStructured private readonly extractStructured?: ExtractStructured
@@ -255,6 +260,7 @@ export class KnowledgeService {
constructor(options: KnowledgeServiceOptions) { constructor(options: KnowledgeServiceOptions) {
this.database = new KnowledgeDatabase(options.databasePath) this.database = new KnowledgeDatabase(options.databasePath)
this.external = new ExternalKnowledgeService(this.database, options.credentialCipher, options.externalFetcher)
this.managedRoot = resolve(options.managedRoot) this.managedRoot = resolve(options.managedRoot)
this.extractStructured = options.extractStructured this.extractStructured = options.extractStructured
this.urlImporter = options.urlImporter ?? new UrlImporter() this.urlImporter = options.urlImporter ?? new UrlImporter()
@@ -293,6 +299,7 @@ export class KnowledgeService {
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.external.dispose()
this.lifecycleController.abort( this.lifecycleController.abort(
new Error('Knowledge service is shutting down') new Error('Knowledge service is shutting down')
) )
@@ -440,6 +447,7 @@ export class KnowledgeService {
private async getEmbeddingIndexCoordinator( private async getEmbeddingIndexCoordinator(
knowledgeBaseId: string knowledgeBaseId: string
): Promise<EmbeddingIndexCoordinator> { ): Promise<EmbeddingIndexCoordinator> {
this.requireLibrary(knowledgeBaseId)
const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId) const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId)
if (existing) { if (existing) {
return existing return existing
@@ -1234,9 +1242,23 @@ export class KnowledgeService {
signal?: AbortSignal, signal?: AbortSignal,
preparedQueryEmbedding?: PreparedQueryEmbedding preparedQueryEmbedding?: PreparedQueryEmbedding
): Promise<KnowledgeRetrievalResponse> { ): Promise<KnowledgeRetrievalResponse> {
signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
signal.throwIfAborted()
const input = knowledgeRetrieveInputSchema.parse(rawInput) const input = knowledgeRetrieveInputSchema.parse(rawInput)
const library = this.requireLibrary(input.knowledgeBaseId) const library = this.requireLibrary(input.knowledgeBaseId, false)
const settings = input.settings ?? library.retrievalSettings const settings = input.settings ?? library.retrievalSettings
const binding = this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)
if (binding) {
const response = await this.external.testRetrieval({instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,commonConfig:binding.commonConfig,providerConfig:binding.providerConfig,testQuery:input.query}, signal)
signal.throwIfAborted()
if (JSON.stringify(this.database.externalStore.listBindings().find(item => item.knowledgeBaseId === library.id)) !== JSON.stringify(binding)) throw new Error('EXTERNAL_KB_CONFIG_CHANGED')
return {
query:input.query,durationMs:response.durationMs,settings,
diagnostics:{external:{provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId},requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},
results:response.results.map((item,index)=>({knowledgeBaseId:library.id,documentTitle:item.documentTitle,sourceDisplayName:item.sourceDisplayName,snippet:item.snippet,rank:index+1,channels:[],scores:{fusedScore:0},external:{kind:'external' as const,provider:binding.provider,instanceId:binding.instanceId,remoteKnowledgeBaseId:binding.remoteKnowledgeBaseId,remoteDocumentId:item.remoteDocumentId,remoteChunkId:item.remoteChunkId,providerScore:item.providerScore,providerScores:item.providerScores,location:item.location,sourceUrl:item.sourceUrl}})),
context:{characterCount:response.results.reduce((sum,item)=>sum+item.snippet.length,0),truncated:false,groups:[]}
}
}
const hasHanQuery = containsHanText(input.query) const hasHanQuery = containsHanText(input.query)
const startedAt = Date.now() const startedAt = Date.now()
const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = [] const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = []
@@ -1665,32 +1687,64 @@ export class KnowledgeService {
response: KnowledgeRetrievalResponse response: KnowledgeRetrievalResponse
}> }>
> { > {
const libraries = knowledgeBaseIds.map((id) => signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal
this.requireLibrary(id) signal.throwIfAborted()
const libraries = [...new Set(knowledgeBaseIds)].map((id) =>
this.requireLibrary(id, false)
) )
const preparedEmbedding = libraries.some( const preparedEmbedding = libraries.some(
(library) => library.retrievalSettings.vectorWeight > 0 (library) => !this.database.externalStore.listBindings().some(item=>item.knowledgeBaseId===library.id) && library.retrievalSettings.vectorWeight > 0
) )
? await this.prepareQueryEmbedding(query, signal) ? await this.prepareQueryEmbedding(query, signal)
: undefined : undefined
signal?.throwIfAborted() signal?.throwIfAborted()
return Promise.all( const outcomes = await Promise.all(
libraries.map(async (library) => ({ libraries.map(async (library) => {
knowledgeBaseId: library.id, try { return { knowledgeBaseId: library.id, response: await this.retrieve(
response: await this.retrieve(
{ {
knowledgeBaseId: library.id, knowledgeBaseId: library.id,
query query
}, },
signal, signal,
preparedEmbedding preparedEmbedding
) )} } catch(error) {
})) signal?.throwIfAborted()
const failure=error instanceof ExternalKnowledgeError ? error.code : error instanceof Error && /^EXTERNAL_KB_[A-Z_]+$/.test(error.message) ? error.message : 'Knowledge retrieval failed'
return {knowledgeBaseId:library.id,response:{query,durationMs:0,settings:library.retrievalSettings,diagnostics:{failure,requestedChannels:[],usedChannels:[],degradedChannels:[],candidateCounts:{},channelDurationMs:{},vectorScannedCount:0,filteredByThresholdCount:0,filteredByBudgetCount:0,rerank:{requested:'none',used:'none',status:'skipped',candidateCount:0,durationMs:0}},results:[],context:{characterCount:0,truncated:false,groups:[]}} as KnowledgeRetrievalResponse}
}
})
) )
let remaining = 48_000
let remainingContext = 48_000
// Spend the shared budget by rank so one library cannot crowd out later libraries.
const ranked = outcomes.flatMap(({ response }) =>
response.results.map(result => ({ response, result }))
).sort((left, right) => left.result.rank - right.result.rank)
for (const { response, result } of ranked) {
const snippet = result.snippet.slice(0, remaining)
if (snippet.length < result.snippet.length) response.context.truncated = true
result.snippet = snippet
remaining -= snippet.length
const group = response.context.groups.find(item => item.resultChunkId === result.chunkId)
if (!group) continue
const content = group.content.slice(0, remainingContext)
if (content.length < group.content.length) response.context.truncated = true
group.content = content
remainingContext -= content.length
}
for (const { response } of outcomes) {
response.results = response.results.filter(result => result.snippet.length > 0)
response.context.groups = response.context.groups.filter(group => group.content.length > 0)
response.context.characterCount = response.context.groups.length
? response.context.groups.reduce((sum, group) => sum + group.content.length, 0)
: response.results.reduce((sum, result) => sum + result.snippet.length, 0)
}
return outcomes
} }
updateSettings(rawInput: KnowledgeSettingsUpdateInput): KnowledgeBase { updateSettings(rawInput: KnowledgeSettingsUpdateInput): KnowledgeBase {
const input = knowledgeSettingsUpdateInputSchema.parse(rawInput) const input = knowledgeSettingsUpdateInputSchema.parse(rawInput)
this.requireLibrary(input.knowledgeBaseId)
return this.database.updateKnowledgeSettings(input) return this.database.updateKnowledgeSettings(input)
} }
@@ -1704,6 +1758,7 @@ export class KnowledgeService {
rawInput: KnowledgeChunkUpdateInput rawInput: KnowledgeChunkUpdateInput
): Promise<ReturnType<KnowledgeDatabase['updateChunk']>> { ): Promise<ReturnType<KnowledgeDatabase['updateChunk']>> {
const input = knowledgeChunkUpdateInputSchema.parse(rawInput) const input = knowledgeChunkUpdateInputSchema.parse(rawInput)
this.requireLibrary(input.knowledgeBaseId)
return this.withDocumentMutation(input.documentId, async () => { return this.withDocumentMutation(input.documentId, async () => {
const current = this.database.getChunkForReference( const current = this.database.getChunkForReference(
input.knowledgeBaseId, input.knowledgeBaseId,
@@ -1733,6 +1788,7 @@ export class KnowledgeService {
async deleteChunk(rawInput: KnowledgeChunkDeleteInput): Promise<boolean> { async deleteChunk(rawInput: KnowledgeChunkDeleteInput): Promise<boolean> {
const input = knowledgeChunkDeleteInputSchema.parse(rawInput) const input = knowledgeChunkDeleteInputSchema.parse(rawInput)
this.requireLibrary(input.knowledgeBaseId)
return this.withDocumentMutation(input.documentId, async () => { return this.withDocumentMutation(input.documentId, async () => {
const deleted = this.database.deleteChunk(input) const deleted = this.database.deleteChunk(input)
if (deleted) { if (deleted) {
@@ -3806,7 +3862,10 @@ export class KnowledgeService {
: candidate : candidate
} }
private requireLibrary(id: string): KnowledgeBase { private requireLibrary(id: string, localOnly = true): KnowledgeBase {
if (localOnly && this.database.externalStore.listBindings().some(item => item.knowledgeBaseId === id)) {
throw new Error('EXTERNAL_KB_READ_ONLY')
}
const library = this.database.getKnowledgeBase(id) const library = this.database.getKnowledgeBase(id)
if (!library) { if (!library) {
throw new Error('知识库不存在') throw new Error('知识库不存在')
@@ -1,5 +1,6 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { z } from 'zod' import { z } from 'zod'
import { remoteQuestionResponseSchema } from '../../shared/remote-question-contracts'
import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts' import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts'
import { import {
AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_LIMITS,
@@ -187,6 +188,10 @@ export const AGENT_PROTOCOL_METHODS = {
params: remoteOwnedPromptAttachRequestSchema, params: remoteOwnedPromptAttachRequestSchema,
result: remoteOwnedPromptStartResultSchema result: remoteOwnedPromptStartResultSchema
}, },
'runtime/respondToQuestion': {
params: remoteQuestionResponseSchema,
result: z.object({}).strict()
},
'runtime/pagePromptTranscript': { 'runtime/pagePromptTranscript': {
params: remoteSemanticTranscriptPageRequestSchema, params: remoteSemanticTranscriptPageRequestSchema,
result: remoteSemanticTranscriptPageResultSchema result: remoteSemanticTranscriptPageResultSchema
@@ -453,6 +453,10 @@ class ManagedRemoteAcpRuntime implements AgentRuntime {
return this.remote.getStatus() return this.remote.getStatus()
} }
respondToQuestion(questionId: string, answers: string[][] = []): Promise<void> {
return this.remote.respondToQuestion(questionId, answers)
}
run( run(
request: AgentExecutionRequest, request: AgentExecutionRequest,
signal: AbortSignal, signal: AbortSignal,
@@ -489,6 +489,17 @@ async function openChannel(fixture = connectionFixture()) {
} }
describe('ProtocolRemoteRuntimeChannel', () => { describe('ProtocolRemoteRuntimeChannel', () => {
it('sends question replies on the authenticated control channel and rejects foreign bindings', async () => {
const fixture = await openChannel()
const response = { bindingId: 'binding-1', operationId: 'operation-1', questionId: 'question-1', answers: [] }
await expect(fixture.channel.respondToQuestion({ ...response, bindingId: 'other' })).rejects.toThrow('binding identity')
await fixture.channel.respondToQuestion(response)
expect(fixture.client.requests.filter(request => request.method === 'runtime/respondToQuestion'))
.toEqual([expect.objectContaining({ method: 'runtime/respondToQuestion', params: response })])
await fixture.channel.close()
await expect(fixture.channel.respondToQuestion(response)).rejects.toThrow()
})
it('maps the complete Agent-owned prompt RPC set', async () => { it('maps the complete Agent-owned prompt RPC set', async () => {
const fixture = await openChannel() const fixture = await openChannel()
const identity = { const identity = {
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { remoteQuestionResponseSchema, type RemoteQuestionResponse } from '../../shared/remote-question-contracts'
import { import {
AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_LIMITS,
ChannelProtocolError, ChannelProtocolError,
@@ -679,6 +680,13 @@ export class ProtocolRemoteRuntimeChannel
) )
} }
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
const parsed = remoteQuestionResponseSchema.parse(request)
this.#assertOwnedPromptIdentity(parsed)
await requestWithTimeout(this.#state.client, 'runtime/respondToQuestion', parsed,
this.#state.controlTimeoutMs)
}
async pageOwnedPromptTranscript( async pageOwnedPromptTranscript(
request: RemoteSemanticTranscriptPageRequest request: RemoteSemanticTranscriptPageRequest
): Promise<RemoteSemanticTranscriptPageResult> { ): Promise<RemoteSemanticTranscriptPageResult> {
+10
View File
@@ -1609,6 +1609,16 @@ const desktopApi: DesktopApi = {
} }
}, },
knowledge: { knowledge: {
externalInstancesList: () => ipcRenderer.invoke(ipcChannels.externalInstancesList),
externalInstancesSave: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesSave,input),
externalInstancesTest: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesTest,input),
externalInstancesSetEnabled: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesSetEnabled,input),
externalInstancesDelete: (input) => ipcRenderer.invoke(ipcChannels.externalInstancesDelete,input),
externalCatalogList: (input) => ipcRenderer.invoke(ipcChannels.externalCatalogList,input),
externalCatalogGet: (input) => ipcRenderer.invoke(ipcChannels.externalCatalogGet,input),
externalBindingsCreate: (input) => ipcRenderer.invoke(ipcChannels.externalBindingsCreate,input),
externalBindingsUpdate: (input) => ipcRenderer.invoke(ipcChannels.externalBindingsUpdate,input),
externalRetrievalTest: (input) => ipcRenderer.invoke(ipcChannels.externalRetrievalTest,input),
getSnapshot: (libraryId?: string) => getSnapshot: (libraryId?: string) =>
ipcRenderer.invoke( ipcRenderer.invoke(
ipcChannels.knowledgeSnapshot, ipcChannels.knowledgeSnapshot,
+10
View File
@@ -10,12 +10,18 @@ type AgentQuestion = Extract<AgentEvent, { type: 'question' }>
type AgentQuestionCardProps = { type AgentQuestionCardProps = {
value: AgentQuestion value: AgentQuestion
id?: string
taskTitle?: string
pendingCount?: number
onReject: () => Promise<void> onReject: () => Promise<void>
onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void> onSubmit: (answers: AgentQuestionAnswer[]) => Promise<void>
} }
export function AgentQuestionCard({ export function AgentQuestionCard({
value, value,
id,
taskTitle,
pendingCount = 1,
onReject, onReject,
onSubmit onSubmit
}: AgentQuestionCardProps): React.JSX.Element { }: AgentQuestionCardProps): React.JSX.Element {
@@ -60,6 +66,8 @@ export function AgentQuestionCard({
return ( return (
<form <form
className="agent-question-card" className="agent-question-card"
id={id}
tabIndex={-1}
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (complete) { if (complete) {
@@ -71,6 +79,8 @@ export function AgentQuestionCard({
<CircleHelp aria-hidden="true" size={18} /> <CircleHelp aria-hidden="true" size={18} />
<strong>{t('question.title')}</strong> <strong>{t('question.title')}</strong>
</header> </header>
{taskTitle && <p>{t('question.task', { title: taskTitle })}</p>}
{pendingCount > 1 && <p>{t('question.pendingCount', { count: pendingCount })}</p>}
{value.questions.map((question, questionIndex) => ( {value.questions.map((question, questionIndex) => (
<fieldset key={`${question.header}:${questionIndex}`}> <fieldset key={`${question.header}:${questionIndex}`}>
<legend> <legend>
+484 -9
View File
@@ -815,6 +815,16 @@ const api: DesktopApi = {
}), }),
}, },
knowledge: { knowledge: {
externalInstancesList: vi.fn(async () => []),
externalInstancesSave: vi.fn(),
externalInstancesTest: vi.fn(),
externalInstancesSetEnabled: vi.fn(),
externalInstancesDelete: vi.fn(),
externalCatalogList: vi.fn(async () => ({ items: [], hasMore: false })),
externalCatalogGet: vi.fn(),
externalBindingsCreate: vi.fn(),
externalBindingsUpdate: vi.fn(),
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 1 })),
getSnapshot: vi.fn(async () => ({ getSnapshot: vi.fn(async () => ({
libraries: [], libraries: [],
sources: [], sources: [],
@@ -1218,6 +1228,289 @@ describe("App", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("project activity integration", () => {
const otherProject = {
...project,
id: "00000000-0000-4000-8000-000000000102",
name: "Background project",
builtInDefault: false,
};
const conversations: ConversationSnapshot[] = [
{ id: "activity-current", projectId, title: "Current discussion", updatedAt: 300, messages: [] },
{ id: "activity-other-latest", projectId: otherProject.id, title: "Other latest", updatedAt: 200, messages: [] },
{ id: "activity-target", projectId: otherProject.id, title: "Exact background discussion", updatedAt: 100,
messages: [{ id: "activity-message", role: "assistant", content: "Exact conversation content", createdAt: 100, state: "complete" }] },
];
const task: AssistantTask = {
id: "activity-task", projectId: otherProject.id,
conversationId: "activity-target", title: "Background scheduled task",
instructions: "Check background progress", origin: "schedule", status: "running",
createdAt: "2026-08-19T00:00:00.000Z",
};
beforeEach(() => {
vi.mocked(api.projects.list).mockResolvedValueOnce([project, otherProject]);
vi.mocked(api.conversations.list).mockResolvedValue(conversations);
vi.mocked(api.tasks.list).mockResolvedValue([task]);
});
it("keeps cross-project task counts independent of initial and current search and clears on refresh", async () => {
const initialTasks = deferred<AssistantTask[]>();
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
render(<App />);
const search = await screen.findByLabelText("搜索对话");
fireEvent.change(search, { target: { value: "no matching conversation" } });
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }]));
const summary = await screen.findByRole("button", { name: /全项目活动/u });
expect(summary).toHaveTextContent("1 个运行中");
expect(screen.queryByTitle("Exact background discussion")).not.toBeInTheDocument();
fireEvent.change(search, { target: { value: "Current discussion" } });
expect(summary).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
const projectRow = within(screen.getByRole("menu", { name: "当前项目" }))
.getByRole("menuitemradio", { name: /Background project/u });
expect(projectRow).toHaveTextContent("1 个运行中");
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
fireEvent.click(summary);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const running = within(dialog).getByRole("region", { name: "运行中" });
expect(within(running).getAllByRole("button")).toHaveLength(1);
expect(within(running).getByRole("button", { name: /Background project Exact background discussion/u })).toBeInTheDocument();
expect(within(dialog).queryByRole("region", { name: "待处理" })).not.toBeInTheDocument();
vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status: "completed" }]);
act(() => conversationQueueChangeListener?.(task.conversationId!));
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "当前项目" }));
expect(within(screen.getByRole("menu")).getByRole("menuitemradio", { name: /Background project/u }))
.not.toHaveTextContent("个运行中");
});
it("opens the exact off-project conversation and clears task selection, search, menu and narrow sidebar", async () => {
vi.mocked(api.tasks.list).mockResolvedValue([task, {
...task, id: "current-task", projectId, conversationId: "activity-current", title: "Current task", status: "idle",
}]);
const { container } = render(<App />);
fireEvent.click(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务"));
fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!);
expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } });
const originalWidth = window.innerWidth;
try {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
act(() => window.dispatchEvent(new Event("resize")));
const sidebar = container.querySelector<HTMLElement>(".sidebar")!;
await waitFor(() => expect(sidebar).toHaveClass("sidebar--closed"));
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
expect(sidebar).toHaveClass("sidebar--closed");
expect(within(screen.getByRole("region", { name: "当前会话的任务" }))
.getByRole("button", { name: /会话任务/u })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
expect(screen.getByLabelText("搜索对话")).toHaveValue("");
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
expect((await within(sidebar).findByTitle("Exact background discussion")).closest("button"))
.toHaveClass("conversation-item--active");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
selectProjectOption(project.name);
expect(sidebar.querySelector(".conversation-actions")).not.toBeInTheDocument();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
});
it("keeps keyboard focus inside activity without dismissing the narrow sidebar", async () => {
const { container } = render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
const originalWidth = window.innerWidth;
try {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 680 });
act(() => window.dispatchEvent(new Event("resize")));
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
const trigger = screen.getByRole("button", { name: /全项目活动/u });
fireEvent.click(trigger);
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
const close = within(dialog).getByRole("button", { name: "关闭全项目活动" });
expect(close).toHaveFocus();
// Native Tab must remain available between controls, even through a React portal.
expect(fireEvent.keyDown(close, { key: "Tab" })).toBe(true);
fireEvent.keyDown(close, { key: "Tab", shiftKey: true });
expect(within(dialog).getByRole("button", { name: /Exact background discussion/u })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
expect(container.querySelector(".sidebar")).not.toHaveClass("sidebar--closed");
expect(trigger).toHaveFocus();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
});
it("respects the Settings leave guard before changing project and conversation", async () => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.click(await screen.findByText("本地工作区"));
await screen.findByRole("heading", { name: "设置中心" });
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(within(screen.getByRole("dialog", { name: "全项目活动" }))
.getByRole("button", { name: /Exact background discussion/u }));
expect(screen.getByRole("heading", { name: "设置中心" })).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent("当前设置有未保存更改");
expect(screen.getByRole("button", { name: "当前项目" })).not.toHaveTextContent("Background project");
expect(screen.getByLabelText("默认工作区目录")).toHaveValue("C:\\Unsaved activity draft");
fireEvent.click(screen.getByRole("button", { name: "放弃更改并关闭" }));
expect(await screen.findByText("Exact conversation content")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "当前项目" })).toHaveTextContent("Background project");
expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument();
});
it.each(["question", "approval"] as const)("preserves live %s across snapshot refresh and clears persisted terminal prompts", async (kind) => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Refresh pending prompt" } });
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
act(() => agentListener?.(kind === "question" ? {
requestId: request.requestId, type: "question", questionId: "refresh-question",
questions: [{ header: "Scope", question: "Refresh question?", options: [], multiple: false, custom: true }],
} : {
requestId: request.requestId, type: "approval", approvalId: "refresh-approval",
title: "Refresh approval", description: "Confirm refresh", toolName: "Bash", argumentSummary: "echo refresh", allowPermanent: false,
}));
if (kind === "question") act(() => agentListener?.({
requestId: request.requestId, type: "question", questionId: "refresh-queued",
questions: [{ header: "Scope", question: "Queued across refresh?", options: [], multiple: false, custom: true }],
}));
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ header: expect.objectContaining({ id: request.conversationId }), messages: expect.arrayContaining([
expect.objectContaining({ role: "assistant", state: "streaming", status: undefined }),
]) }),
])));
const write = vi.mocked(api.conversations.saveLocal).mock.calls.at(-1)![0]
.find((conversation) => conversation.header.id === request.conversationId)!;
const saved = { ...write.header, messages: write.messages };
for (const updatedAt of [saved.updatedAt, saved.updatedAt + 60_000]) {
const refreshed = { ...saved, updatedAt, title: `Snapshot ${updatedAt}` };
vi.mocked(api.conversations.list).mockResolvedValue([refreshed]);
act(() => conversationQueueChangeListener?.(request.conversationId!));
await screen.findAllByText(refreshed.title);
if (kind === "question") expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
expect(screen.getByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
}
let resolveReply: (() => void) | undefined;
if (kind === "question") {
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((resolve) => { resolveReply = resolve; }));
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
await waitFor(() => expect(resolveReply).toBeDefined());
}
vi.mocked(api.conversations.list).mockResolvedValue([{
...saved, updatedAt: saved.updatedAt + 120_000,
messages: saved.messages.map((message) => message.role === "assistant"
? { ...message, state: "complete", content: "Persisted terminal result" } : message),
}]);
act(() => conversationQueueChangeListener?.(request.conversationId!));
await screen.findByText("Persisted terminal result");
await act(async () => resolveReply?.());
expect(screen.getByText("Persisted terminal result")).toBeInTheDocument();
if (kind === "question") expect(screen.getByText("问题与回答")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: kind === "question" ? "提交回答" : "仅此次" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
});
it.each((["question", "approval"] as const).flatMap((kind) =>
(["success", "question", "approval", "done", "failure"] as const).map((outcome) => ({ kind, outcome })),
))("settles $kind response activity safely on $outcome", async ({ kind, outcome }) => {
let resolve!: () => void;
let reject!: (error: Error) => void;
vi.mocked(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval)
.mockImplementationOnce(() => new Promise<void>((yes, no) => { resolve = yes; reject = no; }));
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Response activity" } });
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
const questionEvent = {
requestId: request.requestId, type: "question" as const, questionId: "first-question",
questions: [{ header: "Scope", question: "Response question?", options: [], multiple: false, custom: true }],
};
const approvalEvent = {
requestId: request.requestId, type: "approval" as const, approvalId: "first-approval",
title: "Response approval", description: "Confirm response", toolName: "Bash", argumentSummary: "echo response", allowPermanent: false,
};
act(() => agentListener?.(kind === "question" ? questionEvent : approvalEvent));
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中");
fireEvent.click(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" }));
await waitFor(() => expect(kind === "question" ? api.agent.respondQuestion : api.agent.respondApproval).toHaveBeenCalled());
await act(async () => {
if (outcome === "failure") reject(new Error("Response failed"));
else {
if (outcome === "question") agentListener?.({ ...questionEvent, questionId: "next-question", questions: [{ ...questionEvent.questions[0]!, question: "Next question?" }] });
if (outcome === "approval") agentListener?.({ ...approvalEvent, approvalId: "next-approval", title: "Next approval" });
if (outcome === "done") agentListener?.({ requestId: request.requestId, type: "done" });
resolve();
}
});
const summary = screen.getByRole("button", { name: /全项目活动/u });
expect(summary).toHaveTextContent(outcome === "success" ? "2 个运行中"
: outcome === "done" ? "1 个运行中" : "1 个待处理 1 个运行中");
if (outcome === "success" || outcome === "done") expect(summary).not.toHaveTextContent("待处理");
if (outcome === "question") expect(screen.getByText("Next question?")).toBeInTheDocument();
if (outcome === "approval") expect(screen.getAllByText("Next approval").length).toBeGreaterThan(0);
if (outcome === "failure") expect(screen.getByRole("button", { name: kind === "question" ? "跳过" : "仅此次" })).toBeEnabled();
});
it.each(["question", "approval"] as const)("groups pending %s ahead of running, deduplicates live and task activity, and clears terminal events", async (kind) => {
render(<App />);
await screen.findByRole("button", { name: /全项目活动/u });
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Track live activity" } });
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("2 个运行中");
vi.mocked(api.tasks.list).mockResolvedValue([task, {
...task, id: "live-task", projectId, conversationId: request.conversationId, status: "waiting_approval",
}]);
act(() => {
agentListener?.(kind === "question" ? {
requestId: request.requestId, type: "question", questionId: "activity-question",
questions: [{ header: "范围", question: "继续吗?", options: [], multiple: false, custom: true }],
} : {
requestId: request.requestId, type: "approval", approvalId: "activity-approval",
title: "Approve activity", description: "Confirm execution", toolName: "Bash", argumentSummary: "echo activity", allowPermanent: true,
});
conversationQueueChangeListener?.(request.conversationId!);
});
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理 1 个运行中"));
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
const dialog = screen.getByRole("dialog", { name: "全项目活动" });
expect(within(dialog).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["待处理", "运行中"]);
const pending = within(dialog).getByRole("region", { name: "待处理" });
expect(within(pending).getAllByRole("button")).toHaveLength(1);
expect(pending).toHaveTextContent(kind === "question" ? "等待你的回答" : "等待审批");
expect(within(dialog).getByRole("region", { name: "运行中" })).toHaveTextContent("Exact background discussion");
vi.mocked(api.tasks.list).mockResolvedValue([]);
act(() => {
agentListener?.(kind === "question"
? { requestId: request.requestId, type: "done" }
: { requestId: request.requestId, type: "error", status: "failed", message: "Activity execution failed" });
conversationQueueChangeListener?.(request.conversationId!);
});
await waitFor(() => expect(screen.queryByRole("dialog", { name: "全项目活动" })).not.toBeInTheDocument());
expect(screen.queryByRole("button", { name: /全项目活动/u })).not.toBeInTheDocument();
});
});
it("provides custom minimize, maximize, and close controls", async () => { it("provides custom minimize, maximize, and close controls", async () => {
const { unmount } = render(<App />); const { unmount } = render(<App />);
@@ -1968,9 +2261,14 @@ describe("App", () => {
expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0); expect(await screen.findAllByText("请求写入工作区")).not.toHaveLength(0);
expect(screen.getByText("仅此次")).toBeInTheDocument(); expect(screen.getByText("仅此次")).toBeInTheDocument();
expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument(); expect(screen.getByLabelText("任务结果:发布任务")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个待处理");
fireEvent.click(screen.getByRole("button", { name: "仅此次" }));
await waitFor(() => expect(screen.getByRole("button", { name: /全项目活动/u })).toHaveTextContent("1 个运行中"));
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
}); });
it("idle-preloads only the small Heartbeat route at startup", async () => { it("idle-preloads only the small Heartbeat route at startup", async () => {
const previousLoads = { ...routeModuleLoads };
render(<App />); render(<App />);
expect(window.requestIdleCallback).toHaveBeenCalledWith( expect(window.requestIdleCallback).toHaveBeenCalledWith(
@@ -1987,10 +2285,10 @@ describe("App", () => {
}); });
await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1)); await waitFor(() => expect(routeModuleLoads.heartbeat).toBe(1));
expect(routeModuleLoads).toMatchObject({ expect(routeModuleLoads).toMatchObject({
activity: 0, activity: previousLoads.activity,
knowledge: 0, knowledge: previousLoads.knowledge,
magicNotes: 0, magicNotes: previousLoads.magicNotes,
settings: 0, settings: previousLoads.settings,
}); });
}); });
@@ -4709,6 +5007,43 @@ describe("App", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("preserves distinct external chunks with identical snippets across reference events", async () => {
render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
target: { value: "Find evidence" },
});
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
const references = ["chunk-a", "chunk-b"].map((remoteChunkId) => ({
libraryId: "11111111-1111-4111-8111-111111111111",
libraryName: "External library",
documentName: "Shared title",
sourceName: "Shared source",
snippet: "Identical evidence",
rank: 1,
external: {
kind: "external" as const,
provider: "dify" as const,
instanceId: "instance-a",
remoteKnowledgeBaseId: "dataset-a",
remoteDocumentId: "document-a",
remoteChunkId,
},
}));
act(() => {
for (const reference of [...references, references[0]!]) {
agentListener?.({
requestId: request.requestId,
type: "source-references",
references: [reference],
});
}
agentListener?.({ requestId: request.requestId, type: "done" });
});
expect(await screen.findByText("查看 2 条证据引用")).toBeInTheDocument();
});
it("submits knowledge scope without eager search or prompt injection and merges runtime references", async () => { it("submits knowledge scope without eager search or prompt injection and merges runtime references", async () => {
const libraryId = "11111111-1111-4111-8111-111111111111"; const libraryId = "11111111-1111-4111-8111-111111111111";
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({ vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce({
@@ -5998,6 +6333,41 @@ describe("App", () => {
); );
}); });
it("adds a new external binding to the current selection without re-enabling an excluded library", async () => {
const existing = { id: '11111111-1111-4111-8111-111111111111', name: 'Existing', description: '', storageMode: 'managed' as const, graphEnabled: false, graphStrategy: 'rules' as const, sourceCount: 0, documentCount: 0, indexedDocumentCount: 0 };
const excluded = { ...existing, id: '22222222-2222-4222-8222-222222222222', name: 'Excluded' };
const instance = { id: 'instance-1', name: 'Company Dify', provider: 'dify' as const, baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured' as const, probeStatus: 'healthy' as const, bindingCount: 0 };
const created = { ...existing, id: '33333333-3333-4333-8333-333333333333', name: 'Remote handbook', external: { knowledgeBaseId: '33333333-3333-4333-8333-333333333333', instanceId: instance.id, provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } };
const initial = { libraries: [existing, excluded], selectedLibraryId: existing.id, sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] };
const updated = { ...initial, libraries: [...initial.libraries, created], selectedLibraryId: created.id };
vi.mocked(api.knowledge.getSnapshot).mockResolvedValueOnce(initial).mockResolvedValueOnce(updated);
vi.mocked(api.knowledge.externalInstancesList).mockResolvedValueOnce([instance]).mockResolvedValueOnce([instance]);
vi.mocked(api.knowledge.externalCatalogList).mockResolvedValueOnce({ items: [{ id: 'remote-1', name: 'Remote handbook' }], hasMore: false });
vi.mocked(api.knowledge.externalCatalogGet).mockResolvedValueOnce({ id: 'remote-1', name: 'Remote handbook' });
vi.mocked(api.knowledge.externalBindingsCreate).mockResolvedValueOnce(updated);
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: '44444444-4444-4444-8444-444444444444', projectId, title: 'External scope test', updatedAt: 200, messages: [], knowledgeLibraryIds: [existing.id] }]);
render(<App />);
await screen.findByRole('button', { name: /^External scope test/u });
fireEvent.click(screen.getByRole('button', { name: '知识库' }));
fireEvent.click(await screen.findByRole('button', { name: '新建知识库' }));
fireEvent.click(screen.getByRole('button', { name: 'Dify' }));
fireEvent.click(screen.getByRole('button', { name: '外部实例' }));
fireEvent.click(within(screen.getByRole('menu', { name: '外部实例' })).getByRole('menuitemradio', { name: /Company Dify/u }));
fireEvent.click(await screen.findByRole('button', { name: /Remote handbook/u }));
fireEvent.change(screen.getByLabelText('测试查询'), { target: { value: 'network policy' } });
fireEvent.click(screen.getByRole('button', { name: '测试检索' }));
await waitFor(() => expect(screen.getByRole('button', { name: '添加知识库' })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: '添加知识库' }));
await screen.findByRole('heading', { name: 'Remote handbook' });
expect(api.knowledge.getSnapshot).toHaveBeenLastCalledWith(created.id);
fireEvent.click(screen.getByRole('button', { name: /^External scope test/u }));
openComposerOptions();
fireEvent.click(await screen.findByRole('button', { name: '选择知识库,本次已启用 2 个' }));
expect(screen.getByRole('checkbox', { name: /Existing/u })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Excluded/u })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Remote handbook.*Dify.*Company Dify/u })).toBeChecked();
});
it("ignores stale Git changes after switching projects", async () => { it("ignores stale Git changes after switching projects", async () => {
const secondProject = { const secondProject = {
...project, ...project,
@@ -8049,6 +8419,8 @@ describe("App", () => {
.mockResolvedValueOnce(failed); .mockResolvedValueOnce(failed);
const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length; const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length;
selectProjectOption(firstProject.name); selectProjectOption(firstProject.name);
// Let the project picker restore focus before opening the dismiss-on-blur options.
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
openComposerOptions(); openComposerOptions();
const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" }); const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" });
await act(async () => { await act(async () => {
@@ -8067,6 +8439,9 @@ describe("App", () => {
await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 3)); await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 3));
vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty); vi.mocked(api.runtimeCustomization.getNativeSnapshot).mockResolvedValueOnce(empty);
selectProjectOption(firstProject.name); selectProjectOption(firstProject.name);
await act(async () => { await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); });
openComposerOptions();
expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "true");
await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("group", { name: "OpenCode 专属功能" })).not.toBeInTheDocument());
expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4); expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(callsBefore + 4);
}); });
@@ -10267,6 +10642,89 @@ describe("App", () => {
); );
}); });
it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => {
let reject!: (error: Error) => void;
vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise<void>((_resolve, no) => { reject = no; }));
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Parallel questions" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
const first: Extract<AgentEvent, { type: "question" }> = {
requestId, type: "question", questionId: "queue-first", childTaskId: "queue-child",
questions: [{ header: "Scope", question: "First queued question?", options: [], multiple: false, custom: true }],
};
const second = { ...first, questionId: "queue-second", childTaskId: undefined,
questions: [{ ...first.questions[0]!, question: "Second queued question?" }] };
act(() => {
agentListener?.({ requestId, type: "subagent", childTaskId: "queue-child", expertId: "expert",
expertName: "general", routingMode: "native", state: "running", reason: "Inspect queue task" });
agentListener?.(first);
agentListener?.(first);
});
expect(screen.queryByText("还有 2 个待答问题,请依次回答。")).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Keep this draft" } });
act(() => { agentListener?.(second); agentListener?.(first); });
expect(screen.getByText("任务:Inspect queue task")).toBeInTheDocument();
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
expect(screen.queryByText("Second queued question?")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
const taskCard = screen.getByText("Inspect queue task").closest("details")!;
expect(taskCard).toHaveTextContent("等待你的回答");
taskCard.open = true;
fireEvent(taskCard, new Event("toggle"));
const form = document.querySelector<HTMLFormElement>(".agent-question-card")!;
form.scrollIntoView = vi.fn();
fireEvent.click(await screen.findByRole("button", { name: "前往问答表单" }));
expect(document.activeElement).toHaveClass("agent-question-card");
expect(form.scrollIntoView).toHaveBeenCalledWith({ block: "center" });
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
const third = { ...second, questionId: "queue-third",
questions: [{ ...first.questions[0]!, question: "Third queued question?" }] };
await act(async () => { agentListener?.(third); reject(new Error("Queue reply failed")); });
expect(screen.getByRole("alert")).toHaveTextContent("Queue reply failed");
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Keep this draft");
fireEvent.click(screen.getByRole("button", { name: "提交回答" }));
await screen.findByText("Second queued question?");
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-first", [["Keep this draft"]]);
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("");
act(() => agentListener?.(first));
expect(screen.getByText("还有 2 个待答问题,请依次回答。")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
await screen.findByText("Third queued question?");
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-second", undefined);
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
await waitFor(() => expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument());
expect(api.agent.respondQuestion).toHaveBeenLastCalledWith("queue-third", undefined);
expect(screen.getAllByText("问题与回答")).toHaveLength(3);
act(() => agentListener?.(first));
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
});
it("removes remotely resolved questions without recording a skip or blocking the next question", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "Remote questions" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => {
for (const id of ["first", "second"]) agentListener?.({ requestId, type: "question", questionId: id,
questions: [{ header: "Input", question: `Remote ${id}?`, options: [], multiple: false, custom: true }] });
});
expect(screen.getByText("Remote first?")).toBeInTheDocument();
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
expect(screen.getByText("Remote second?")).toBeInTheDocument();
expect(screen.queryByText("问题与回答")).not.toBeInTheDocument();
expect(api.agent.respondQuestion).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { target: { value: "Retain draft" } });
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "first" }));
expect(screen.getByRole("textbox", { name: "其他回答" })).toHaveValue("Retain draft");
act(() => agentListener?.({ requestId, type: "question-resolved", questionId: "second" }));
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /全项目活动/u })).not.toHaveTextContent("待处理");
});
it("renders, saves and reloads OpenCode answers across question rounds", async () => { it("renders, saves and reloads OpenCode answers across question rounds", async () => {
const view = render(<App />); const view = render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
@@ -10363,7 +10821,7 @@ describe("App", () => {
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
}); });
it.each(["done", "question", "failure"] as const)( it.each(["done", "cancelled", "error", "question", "resolved", "failure"] as const)(
"preserves question state when %s occurs during answer submission", "preserves question state when %s occurs during answer submission",
async (outcome) => { async (outcome) => {
let resolve!: () => void; let resolve!: () => void;
@@ -10386,6 +10844,10 @@ describe("App", () => {
act(() => agentListener?.({ act(() => agentListener?.({
requestId: request.requestId, type: "question", questionId: "first", questions, requestId: request.requestId, type: "question", questionId: "first", questions,
})); }));
act(() => agentListener?.({
requestId: request.requestId, type: "question", questionId: "queued",
questions: [{ ...questions[0]!, question: "Already queued?" }],
}));
fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), { fireEvent.change(screen.getByRole("textbox", { name: "其他回答" }), {
target: { value: "保留完整自定义回答\n" + "检查".repeat(600) }, target: { value: "保留完整自定义回答\n" + "检查".repeat(600) },
}); });
@@ -10395,8 +10857,12 @@ describe("App", () => {
if (outcome === "failure") { if (outcome === "failure") {
reject(new Error("Answer delivery failed")); reject(new Error("Answer delivery failed"));
} else { } else {
agentListener?.(outcome === "done" agentListener?.(outcome === "cancelled" || outcome === "error"
? { requestId: request.requestId, type: "done" } ? { requestId: request.requestId, type: "error", status: outcome === "cancelled" ? "cancelled" : "failed", message: "Terminal queue result" }
: outcome === "done"
? { requestId: request.requestId, type: "done" }
: outcome === "resolved"
? { requestId: request.requestId, type: "question-resolved", questionId: "first" }
: { requestId: request.requestId, type: "question", questionId: "second", : { requestId: request.requestId, type: "question", questionId: "second",
questions: [{ ...questions[0]!, question: "是否继续下一步?" }] }); questions: [{ ...questions[0]!, question: "是否继续下一步?" }] });
resolve(); resolve();
@@ -10416,9 +10882,18 @@ describe("App", () => {
})]), })]),
})]), })]),
)); ));
if (outcome === "question") { if (outcome === "question" || outcome === "resolved") {
expect(screen.getByText("是否继续下一步?")).toBeInTheDocument(); expect(screen.getByText("Already queued?")).toBeInTheDocument();
expect(screen.queryByText("是否继续下一步?")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "提交回答" })).toBeInTheDocument();
if (outcome === "question") {
fireEvent.click(screen.getByRole("button", { name: "跳过" }));
expect(await screen.findByText("是否继续下一步?")).toBeInTheDocument();
}
} else {
expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument();
expect(screen.queryByText("Already queued?")).not.toBeInTheDocument();
if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result");
} }
} }
}, },
+222 -93
View File
@@ -56,6 +56,7 @@ import {
type SetStateAction, type SetStateAction,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { import type {
ApprovalDecision, ApprovalDecision,
@@ -161,6 +162,8 @@ import {
ScopeBadge, ScopeBadge,
} from "./WorkspacePrimitives"; } from "./WorkspacePrimitives";
import { ProjectSwitcher } from "./ProjectSwitcher"; import { ProjectSwitcher } from "./ProjectSwitcher";
import { ProjectActivity } from "./ProjectActivity";
import { deriveConversationActivity } from "./conversation-activity";
import { import {
RightAssistantSidebar, RightAssistantSidebar,
type AssistantSidebarTab, type AssistantSidebarTab,
@@ -1461,10 +1464,14 @@ function mergePersistedConversations(
); );
const messages = [ const messages = [
...conversation.messages.map((message) => { ...conversation.messages.map((message) => {
const localMessage = localMessageById.get(message.id);
if (!localIsNewer) { if (!localIsNewer) {
// Pending prompts are live-only and are omitted from persisted messages.
if (localMessage?.state === "streaming" && message.state === "streaming") {
return { ...message, approval: localMessage.approval, pendingQuestions: localMessage.pendingQuestions };
}
return message; return message;
} }
const localMessage = localMessageById.get(message.id);
if ( if (
!localMessage || !localMessage ||
persistedTerminalStateOverridesLocal(localMessage, message) persistedTerminalStateOverridesLocal(localMessage, message)
@@ -2547,11 +2554,13 @@ function App(): React.JSX.Element {
tasks: [], tasks: [],
}, },
); );
const [externalInstances, setExternalInstances] = useState<import('../../shared/external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>([]);
const [knowledgeLoading, setKnowledgeLoading] = useState(true); const [knowledgeLoading, setKnowledgeLoading] = useState(true);
const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>(); const [knowledgeLoadError, setKnowledgeLoadError] = useState<string>();
const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0); const [knowledgeOperationCount, setKnowledgeOperationCount] = useState(0);
const knowledgeOperationCountRef = useRef(knowledgeOperationCount); const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
const knowledgeLoadRequestRef = useRef(0); const knowledgeLoadRequestRef = useRef(0);
const citationRequestRef = useRef(0);
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined); const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null); const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null); const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null);
@@ -2855,6 +2864,7 @@ function App(): React.JSX.Element {
?.focus(); ?.focus();
}); });
const closeOnEscape = (event: KeyboardEvent): void => { const closeOnEscape = (event: KeyboardEvent): void => {
if (sidebarRef.current?.closest<HTMLElement>(".app-shell")?.inert) return;
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
closeNarrowSidebar(); closeNarrowSidebar();
@@ -3763,6 +3773,26 @@ function App(): React.JSX.Element {
), ),
[projects, tWorkspace], [projects, tWorkspace],
); );
const projectActivity = useMemo(
() => deriveConversationActivity(
conversations.map((conversation) => ({
...conversation,
title: conversationTitles.get(conversation.id) ?? conversation.title,
})),
assistantTasks,
activeConversationIds,
projects.map((project) => ({
id: project.id,
name: projectNames.get(project.id) ?? project.name,
})),
tWorkspace("projectActivity.unassigned"),
),
[conversations, conversationTitles, assistantTasks, activeConversationIds,
projects, projectNames, tWorkspace],
);
const activityByConversationId = new Map(
projectActivity.activities.map((activity) => [activity.conversationId, activity]),
);
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>( const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() => () =>
conversations.flatMap((conversation) => conversations.flatMap((conversation) =>
@@ -3939,13 +3969,16 @@ function App(): React.JSX.Element {
async (libraryId?: string): Promise<KnowledgeSnapshot> => { async (libraryId?: string): Promise<KnowledgeSnapshot> => {
const requestId = ++knowledgeLoadRequestRef.current; const requestId = ++knowledgeLoadRequestRef.current;
try { try {
const snapshot = const [snapshot, instances] = await Promise.all([
await window.goodbuddy.knowledge.getSnapshot(libraryId); window.goodbuddy.knowledge.getSnapshot(libraryId),
window.goodbuddy.knowledge.externalInstancesList(),
]);
if (requestId !== knowledgeLoadRequestRef.current) { if (requestId !== knowledgeLoadRequestRef.current) {
return snapshot; return snapshot;
} }
failedKnowledgeLibraryIdRef.current = undefined; failedKnowledgeLibraryIdRef.current = undefined;
setKnowledgeSnapshot(snapshot); setKnowledgeSnapshot(snapshot);
setExternalInstances(instances);
setKnowledgeLoadError(undefined); setKnowledgeLoadError(undefined);
const availableIds = new Set( const availableIds = new Set(
snapshot.libraries.map((library) => library.id), snapshot.libraries.map((library) => library.id),
@@ -4232,6 +4265,27 @@ function App(): React.JSX.Element {
return; return;
} }
const liveMessage = conversationsRef.current
.find((conversation) => conversation.id === run.conversationId)
?.messages.find((message) => message.id === run.messageId);
if (event.type === "question-resolved") {
updateMessage(run.conversationId, run.messageId, (message) => {
if (!message.pendingQuestions?.some((question) => question.questionId === event.questionId)) return message;
const pendingQuestions = message.pendingQuestions.filter((question) => question.questionId !== event.questionId);
if (!pendingQuestions.length && !message.approval && message.state === "streaming") {
setAssistantTasks((current) => current.map((task) =>
task.id === event.requestId && task.status === "waiting_approval" ? { ...task, status: "running" } : task,
));
}
return { ...message, pendingQuestions };
});
return;
}
if (event.type === "question" && (
liveMessage?.state !== "streaming" ||
liveMessage.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
liveMessage.answeredQuestions?.some((question) => question.questionId === event.questionId)
)) return;
setAssistantTasks((current) => { setAssistantTasks((current) => {
let changed = false; let changed = false;
const updated = current.map((task) => { const updated = current.map((task) => {
@@ -4245,7 +4299,9 @@ function App(): React.JSX.Element {
? "completed" ? "completed"
: event.type === "error" : event.type === "error"
? event.status ? event.status
: "running"; : liveMessage?.pendingQuestions?.length || liveMessage?.approval
? "waiting_approval"
: "running";
const completedAt = const completedAt =
event.type === "done" || event.type === "error" event.type === "done" || event.type === "error"
? new Date().toISOString() ? new Date().toISOString()
@@ -4640,11 +4696,18 @@ function App(): React.JSX.Element {
}, },
})); }));
} else if (event.type === "question") { } else if (event.type === "question") {
updateMessage(run.conversationId, run.messageId, (message) => ({ updateMessage(run.conversationId, run.messageId, (message) => {
...message, if (message.state !== "streaming" ||
status: undefined, message.pendingQuestions?.some((question) => question.questionId === event.questionId) ||
question: event, message.answeredQuestions?.some((question) => question.questionId === event.questionId)) {
})); return message;
}
return {
...message,
status: undefined,
pendingQuestions: [...(message.pendingQuestions ?? []), event],
};
});
} else if (event.type === "artifact") { } else if (event.type === "artifact") {
updateMessage(run.conversationId, run.messageId, (message) => ({ updateMessage(run.conversationId, run.messageId, (message) => ({
...message, ...message,
@@ -4673,27 +4736,19 @@ function App(): React.JSX.Element {
})); }));
} else if (event.type === "source-references") { } else if (event.type === "source-references") {
updateMessage(run.conversationId, run.messageId, (message) => { updateMessage(run.conversationId, run.messageId, (message) => {
const referenceKey = (reference: KnowledgeSearchReference): string =>
[
reference.libraryId,
reference.documentId,
reference.chunkId ?? "",
reference.locator ?? "",
reference.snippet,
].join("\0");
const incoming = [ const incoming = [
...new Map( ...new Map(
event.references.map((reference) => [ event.references.map((reference) => [
referenceKey(reference), knowledgeReferenceKey(reference),
reference, reference,
]), ]),
).values(), ).values(),
]; ];
const incomingKeys = new Set(incoming.map(referenceKey)); const incomingKeys = new Set(incoming.map(knowledgeReferenceKey));
const references = [ const references = [
...incoming, ...incoming,
...(message.sourceReferences ?? []).filter( ...(message.sourceReferences ?? []).filter(
(reference) => !incomingKeys.has(referenceKey(reference)), (reference) => !incomingKeys.has(knowledgeReferenceKey(reference)),
), ),
].slice(0, 20); ].slice(0, 20);
return { return {
@@ -4812,7 +4867,7 @@ function App(): React.JSX.Element {
) )
: message.contextCompressions, : message.contextCompressions,
approval: undefined, approval: undefined,
question: undefined, pendingQuestions: undefined,
tools: toolTerminalState tools: toolTerminalState
? message.tools?.map((tool) => ? message.tools?.map((tool) =>
tool.state === "pending" || tool.state === "running" tool.state === "pending" || tool.state === "running"
@@ -6715,11 +6770,16 @@ function App(): React.JSX.Element {
const openCitationContext = useCallback( const openCitationContext = useCallback(
async (reference: KnowledgeSearchReference): Promise<void> => { async (reference: KnowledgeSearchReference): Promise<void> => {
const request = ++citationRequestRef.current;
if (reference.external) {
setCitationDialog({ reference, loading: false });
return;
}
setCitationDialog({ setCitationDialog({
reference, reference,
loading: true, loading: true,
}); });
if (!reference.chunkId) { if (!reference.chunkId || !reference.documentId) {
setCitationDialog({ setCitationDialog({
reference, reference,
loading: false, loading: false,
@@ -6733,6 +6793,7 @@ function App(): React.JSX.Element {
documentId: reference.documentId, documentId: reference.documentId,
chunkId: reference.chunkId, chunkId: reference.chunkId,
}); });
if (request !== citationRequestRef.current) return;
setCitationDialog({ setCitationDialog({
reference, reference,
loading: false, loading: false,
@@ -6747,6 +6808,7 @@ function App(): React.JSX.Element {
}, },
}); });
} catch (reason) { } catch (reason) {
if (request !== citationRequestRef.current) return;
setCitationDialog({ setCitationDialog({
reference, reference,
loading: false, loading: false,
@@ -6762,7 +6824,7 @@ function App(): React.JSX.Element {
const openCitationSource = useCallback( const openCitationSource = useCallback(
async (reference: KnowledgeSearchReference): Promise<void> => { async (reference: KnowledgeSearchReference): Promise<void> => {
if (!reference.chunkId) { if (reference.external || !reference.chunkId || !reference.documentId) {
return; return;
} }
try { try {
@@ -7123,7 +7185,9 @@ function App(): React.JSX.Element {
startedAt, startedAt,
}, },
...current, ...current,
].slice(0, 100), ].filter((task, index) =>
index < 100 || task.status === "running" || task.status === "waiting_approval",
),
); );
recordActivity({ recordActivity({
conversationId, conversationId,
@@ -7349,6 +7413,12 @@ function App(): React.JSX.Element {
approvalId: string, approvalId: string,
decision: ApprovalDecision, decision: ApprovalDecision,
): Promise<void> => { ): Promise<void> => {
const pendingMessage = conversationsRef.current
.find((conversation) => conversation.id === conversationId)
?.messages.find((message) => message.id === messageId);
if (pendingMessage?.approval?.id !== approvalId) return;
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
try { try {
await window.goodbuddy.agent.respondApproval(approvalId, decision); await window.goodbuddy.agent.respondApproval(approvalId, decision);
const approved = decision !== "deny"; const approved = decision !== "deny";
@@ -7380,20 +7450,29 @@ function App(): React.JSX.Element {
return record; return record;
}); });
}); });
updateMessage(conversationId, messageId, (message) => ({ updateMessage(conversationId, messageId, (message) => {
...message, if (message.approval?.id !== approvalId) return message;
approval: undefined, if (!message.pendingQuestions?.length) {
status: setAssistantTasks((current) => current.map((task) =>
approved && message.task task.id === taskId && task.status === "waiting_approval"
? undefined ? { ...task, status: "running" }
: approved : task,
? tRef.current("chat.approval.executing", { ));
decision: decisionLabel, }
}) return {
: tRef.current("chat.approval.denied"), ...message,
})); approval: undefined,
status: message.pendingQuestions?.length
? message.status
: approved && message.task
? undefined
: approved
? tRef.current("chat.approval.executing", { decision: decisionLabel })
: tRef.current("chat.approval.denied"),
};
});
} catch { } catch {
updateMessage(conversationId, messageId, (message) => ({ updateMessage(conversationId, messageId, (message) => message.approval?.id !== approvalId ? message : ({
...message, ...message,
status: tRef.current("chat.approval.responseFailed"), status: tRef.current("chat.approval.responseFailed"),
})); }));
@@ -7409,35 +7488,48 @@ function App(): React.JSX.Element {
questionId: string, questionId: string,
answers?: AgentQuestionAnswer[], answers?: AgentQuestionAnswer[],
): Promise<void> => { ): Promise<void> => {
const question = conversationsRef.current const pendingMessage = conversationsRef.current
.find((conversation) => conversation.id === conversationId) .find((conversation) => conversation.id === conversationId)
?.messages.find((message) => message.id === messageId)?.question; ?.messages.find((message) => message.id === messageId);
const question = pendingMessage?.pendingQuestions?.[0];
if (!question || question.questionId !== questionId) { if (!question || question.questionId !== questionId) {
return; return;
} }
const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()]
.find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0];
await window.goodbuddy.agent.respondQuestion(questionId, answers); await window.goodbuddy.agent.respondQuestion(questionId, answers);
updateMessage(conversationId, messageId, (message) => ({ updateMessage(conversationId, messageId, (message) => {
...message, const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId);
answeredQuestions: [ const resumed = message.pendingQuestions?.some((item) => item.questionId === questionId) &&
...(message.answeredQuestions ?? []), !pendingQuestions?.length && !message.approval && message.state === "streaming";
{ if (resumed) {
questionId, setAssistantTasks((current) => current.map((task) =>
skipped: answers === undefined, task.id === taskId && task.status === "waiting_approval"
questions: question.questions.map((item, index) => ({ ? { ...task, status: "running" }
...item, : task,
answer: answers?.[index], ));
})), }
}, return {
], ...message,
question: message.question?.questionId === questionId answeredQuestions: [
? undefined ...(message.answeredQuestions ?? []).filter((item) => item.questionId !== questionId),
: message.question, {
status: message.question?.questionId === questionId && message.state === "streaming" questionId,
? answers skipped: answers === undefined,
? tRef.current("chat.status.answerSubmitted") questions: question.questions.map((item, index) => ({
: tRef.current("chat.status.questionSkipped") ...item,
: message.status, answer: answers?.[index],
})); })),
},
],
pendingQuestions,
status: resumed
? answers
? tRef.current("chat.status.answerSubmitted")
: tRef.current("chat.status.questionSkipped")
: message.status,
};
});
}, },
[updateMessage], [updateMessage],
); );
@@ -7752,29 +7844,46 @@ function App(): React.JSX.Element {
}; };
const openActivityConversation = (conversationId: string): void => { const openActivityConversation = (conversationId: string): void => {
const conversation = conversations.find( const open = async (): Promise<void> => {
(candidate) => candidate.id === conversationId, let conversation = conversationsRef.current.find(
); (candidate) => candidate.id === conversationId,
if (!conversation) { );
notify({ if (!conversation) {
tone: "info", try {
message: t("notices.conversationDeleted"), const persisted = await window.goodbuddy.conversations.list();
}); conversation = persisted.find((candidate) => candidate.id === conversationId);
return; setConversations((current) => mergePersistedConversations(
} current, persisted, persistedLocalConversationsRef.current,
if (conversation.projectId) { ));
setActiveProjectId(conversation.projectId); } catch {
} notify({ tone: "error", message: t("notices.remoteConversationRefreshFailed") });
setActiveId(conversationId); return;
setUnreadConversationIds((current) => { }
if (!current.has(conversationId)) {
return current;
} }
const next = new Set(current); if (!conversation) {
next.delete(conversationId); notify({ tone: "info", message: t("notices.conversationDeleted") });
return next; return;
}); }
setView("chat"); setActiveProjectId(conversation.projectId ?? "");
setConversationActionsId("");
setSelectedAssistantTaskId(undefined);
setSearchQuery("");
setActiveId(conversationId);
setUnreadConversationIds((current) => {
if (!current.has(conversationId)) return current;
const next = new Set(current);
next.delete(conversationId);
return next;
});
commitView("chat");
if (narrowWindow) closeNarrowSidebar();
};
const requestLeave = settingsLeaveRequesterRef.current;
if (viewRef.current === "settings" && requestLeave) {
requestLeave(() => { void open(); });
} else {
void open();
}
}; };
const openAssistantTask = (task: AssistantTask): void => { const openAssistantTask = (task: AssistantTask): void => {
@@ -8192,7 +8301,7 @@ function App(): React.JSX.Element {
id="primary-sidebar" id="primary-sidebar"
inert={!sidebarOpen} inert={!sidebarOpen}
onKeyDown={(event) => { onKeyDown={(event) => {
if (mainSidebarOpen) { if (mainSidebarOpen && event.currentTarget.contains(event.target as Node)) {
trapTabFocus(event, sidebarRef.current); trapTabFocus(event, sidebarRef.current);
} }
}} }}
@@ -8220,6 +8329,7 @@ function App(): React.JSX.Element {
<ProjectSwitcher <ProjectSwitcher
activeProjectId={activeProjectId} activeProjectId={activeProjectId}
activityByProjectId={projectActivity.byProjectId}
recoveryByProjectId={projectRecoveryByProjectId} recoveryByProjectId={projectRecoveryByProjectId}
runtimeSettings={runtimeSettings} runtimeSettings={runtimeSettings}
onArchive={archiveProject} onArchive={archiveProject}
@@ -8233,6 +8343,10 @@ function App(): React.JSX.Element {
projects={projects} projects={projects}
remoteProjectsEnabled={remoteProjectsEnabled} remoteProjectsEnabled={remoteProjectsEnabled}
/> />
<ProjectActivity
activities={projectActivity.activities}
onOpenConversation={openActivityConversation}
/>
{activeProject?.kind !== "channel" && ( {activeProject?.kind !== "channel" && (
<button className="new-chat" onClick={newConversation} type="button"> <button className="new-chat" onClick={newConversation} type="button">
@@ -8383,6 +8497,7 @@ function App(): React.JSX.Element {
conversation, conversation,
t("conversation.defaultTitle"), t("conversation.defaultTitle"),
); );
const activity = activityByConversationId.get(conversation.id);
const branchSourceTitle = conversation.branch const branchSourceTitle = conversation.branch
? (conversationTitles.get( ? (conversationTitles.get(
conversation.branch.sourceConversationId, conversation.branch.sourceConversationId,
@@ -8497,12 +8612,16 @@ function App(): React.JSX.Element {
</time> </time>
</small> </small>
</button> </button>
{activeConversationIds.has(conversation.id) && ( {activity && (
<span <span
aria-label={t("conversation.active")} aria-label={activity.status === "running"
className="conversation-activity-indicator" ? t("conversation.active")
: tWorkspace(`projectActivity.status.${activity.status}`)}
className={activity.status === "running"
? "conversation-activity-indicator"
: "conversation-activity-indicator conversation-activity-indicator--attention"}
role="status" role="status"
title={t("conversation.active")} title={tWorkspace(`projectActivity.status.${activity.status}`)}
/> />
)} )}
<button <button
@@ -9527,9 +9646,13 @@ function App(): React.JSX.Element {
{t("composer.knowledge.scope")} {t("composer.knowledge.scope")}
</strong> </strong>
{knowledgeSnapshot.libraries.map( {knowledgeSnapshot.libraries.map(
(library) => ( (library) => {
const instance = externalInstances.find(item => item.id === library.external?.instanceId);
const externalStatus = !instance ? 'temporarily-unavailable' : !instance.enabled ? 'instance-disabled' : instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed' ? 'credential-error' : ['failed', 'unreachable'].includes(instance.probeStatus) ? 'temporarily-unavailable' : 'ready';
return (
<label key={library.id}> <label key={library.id}>
<input <input
disabled={!!library.external && externalStatus !== 'ready'}
checked={enabledKnowledgeLibraryIds.includes( checked={enabledKnowledgeLibraryIds.includes(
library.id, library.id,
)} )}
@@ -9553,7 +9676,7 @@ function App(): React.JSX.Element {
/> />
<span>{library.name}</span> <span>{library.name}</span>
<small> <small>
{t( {library.external ? `${({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[library.external.provider]} · ${instance?.name ?? library.external.instanceId} · ${t(`external.states.${externalStatus}`, { ns: 'knowledge' })}` : t(
"composer.knowledge.documents", "composer.knowledge.documents",
{ {
count: library.documentCount, count: library.documentCount,
@@ -9561,7 +9684,7 @@ function App(): React.JSX.Element {
)} )}
</small> </small>
</label> </label>
), )},
)} )}
<div className="knowledge-scope__retrieval-mode"> <div className="knowledge-scope__retrieval-mode">
<strong> <strong>
@@ -10269,6 +10392,12 @@ function App(): React.JSX.Element {
} }
> >
<KnowledgeWorkspace <KnowledgeWorkspace
externalInstances={externalInstances}
notify={notify}
onExternalChanged={async (snapshot, createdId) => {
await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? knowledgeSnapshot.selectedLibraryId);
if (createdId) setEnabledKnowledgeLibraryIds(current => [...new Set([...current, createdId])]);
}}
documents={knowledgeSnapshot.documents} documents={knowledgeSnapshot.documents}
evidence={knowledgeSnapshot.evidence} evidence={knowledgeSnapshot.evidence}
graphNodes={knowledgeSnapshot.graphNodes} graphNodes={knowledgeSnapshot.graphNodes}
@@ -10784,10 +10913,10 @@ function App(): React.JSX.Element {
context={citationDialog.context} context={citationDialog.context}
error={citationDialog.error} error={citationDialog.error}
loading={citationDialog.loading} loading={citationDialog.loading}
onClose={() => setCitationDialog(undefined)} onClose={() => { citationRequestRef.current++; setCitationDialog(undefined); }}
onOpenSource={async () => { onOpenSource={async () => {
const { reference } = citationDialog; const { reference } = citationDialog;
if (!reference.chunkId) { if (reference.external || !reference.chunkId || !reference.documentId) {
throw new Error(t("chat.citations.contextUnavailable")); throw new Error(t("chat.citations.contextUnavailable"));
} }
await window.goodbuddy.knowledge.openReferenceSource({ await window.goodbuddy.knowledge.openReferenceSource({
+2 -2
View File
@@ -225,10 +225,10 @@ describe('ChatTimeline', () => {
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible() expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{ rerender(<ChatTimeline {...props} messages={[{
...message, status: 'OpenCode 正在处理请求', ...message, status: 'OpenCode 正在处理请求',
question: { pendingQuestions: [{
requestId: 'question-request', type: 'question', questionId: 'q-1', requestId: 'question-request', type: 'question', questionId: 'q-1',
questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }] questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }]
} }]
}]} />) }]} />)
expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible() expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible()
expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument() expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument()
+40 -12
View File
@@ -69,7 +69,7 @@ export type Message = {
argumentSummary?: string argumentSummary?: string
allowPermanent?: boolean allowPermanent?: boolean
} }
question?: Extract<AgentEvent, { type: 'question' }> pendingQuestions?: Extract<AgentEvent, { type: 'question' }>[]
answeredQuestions?: ConversationMessage['answeredQuestions'] answeredQuestions?: ConversationMessage['answeredQuestions']
sources?: string[] sources?: string[]
sourceReferences?: KnowledgeSearchReference[] sourceReferences?: KnowledgeSearchReference[]
@@ -297,10 +297,12 @@ function ToolExecutionList({
const SubagentStatusCard = memo(function SubagentStatusCard({ const SubagentStatusCard = memo(function SubagentStatusCard({
renderHtml, renderHtml,
subagent, subagent,
questionFormId,
onCopy onCopy
}: { }: {
renderHtml: boolean renderHtml: boolean
subagent: SubagentActivity subagent: SubagentActivity
questionFormId?: string
onCopy: CopyContent onCopy: CopyContent
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('app') const { t } = useTranslation('app')
@@ -371,11 +373,11 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
</small> </small>
</span> </span>
<span <span
aria-label={t(`chat.subagents.states.${subagent.state}`)} aria-label={questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`} className={`subagent-status-card__status subagent-status-card__status--${subagent.state}`}
> >
<StateIcon aria-hidden="true" size={14} /> <StateIcon aria-hidden="true" size={14} />
{t(`chat.subagents.states.${subagent.state}`)} {questionFormId ? t('chat.status.waitingForAnswer') : t(`chat.subagents.states.${subagent.state}`)}
</span> </span>
<ChevronRight <ChevronRight
aria-hidden="true" aria-hidden="true"
@@ -385,6 +387,15 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
</summary> </summary>
{expanded && ( {expanded && (
<div className="subagent-status-card__details"> <div className="subagent-status-card__details">
{questionFormId && (
<button className="secondary-button" type="button" onClick={() => {
const form = document.getElementById(questionFormId)
form?.scrollIntoView({ block: 'center' })
form?.focus({ preventScroll: true })
}}>
{t('workspace:question.locate')}
</button>
)}
{progress && progress.length > 0 && ( {progress && progress.length > 0 && (
<section aria-label={t('chat.subagents.progress')}> <section aria-label={t('chat.subagents.progress')}>
<strong>{t('chat.subagents.progress')}</strong> <strong>{t('chat.subagents.progress')}</strong>
@@ -446,10 +457,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
function SubagentStatusList({ function SubagentStatusList({
renderHtml, renderHtml,
subagents, subagents,
pendingQuestions,
questionFormId,
onCopy onCopy
}: { }: {
renderHtml: boolean renderHtml: boolean
subagents: SubagentActivity[] subagents: SubagentActivity[]
pendingQuestions?: Message['pendingQuestions']
questionFormId?: string
onCopy: CopyContent onCopy: CopyContent
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('app') const { t } = useTranslation('app')
@@ -464,6 +479,7 @@ function SubagentStatusList({
key={subagent.childTaskId} key={subagent.childTaskId}
renderHtml={renderHtml} renderHtml={renderHtml}
subagent={subagent} subagent={subagent}
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
onCopy={onCopy} onCopy={onCopy}
/> />
))} ))}
@@ -573,6 +589,9 @@ function ChatMessageRowView({
? t('chat.contextCompression.agentFailed') ? t('chat.contextCompression.agentFailed')
: t('chat.contextCompression.failed') : t('chat.contextCompression.failed')
const question = message.pendingQuestions?.[0]
const questionTask = question?.childTaskId ? subagentsById.get(question.childTaskId) : undefined
const questionFormId = `agent-question-${message.id}`
const activeTool = message.tools?.find((tool) => const activeTool = message.tools?.find((tool) =>
tool.state === 'pending' || tool.state === 'running') tool.state === 'pending' || tool.state === 'running')
const activeSubagent = message.subagents?.some((subagent) => const activeSubagent = message.subagents?.some((subagent) =>
@@ -580,7 +599,7 @@ function ChatMessageRowView({
const activeCompression = compressionMarkers.find((marker) => const activeCompression = compressionMarkers.find((marker) =>
marker.state === 'compressing') marker.state === 'compressing')
const statusText = message.role === 'assistant' && message.state === 'streaming' const statusText = message.role === 'assistant' && message.state === 'streaming'
? message.question ? question
? t('chat.status.waitingForAnswer') ? t('chat.status.waitingForAnswer')
: message.approval : message.approval
? t('chat.status.waitingForApproval') ? t('chat.status.waitingForApproval')
@@ -713,6 +732,8 @@ function ChatMessageRowView({
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} /> <ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
) : item.kind === 'subagents' ? ( ) : item.kind === 'subagents' ? (
<SubagentStatusList <SubagentStatusList
pendingQuestions={message.pendingQuestions}
questionFormId={questionFormId}
onCopy={onCopyMessage} onCopy={onCopyMessage}
key={item.id} key={item.id}
renderHtml={renderAssistantHtml} renderHtml={renderAssistantHtml}
@@ -771,6 +792,8 @@ function ChatMessageRowView({
)} )}
{unorderedSubagents && unorderedSubagents.length > 0 && ( {unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList <SubagentStatusList
pendingQuestions={message.pendingQuestions}
questionFormId={questionFormId}
onCopy={onCopyMessage} onCopy={onCopyMessage}
renderHtml={renderAssistantHtml} renderHtml={renderAssistantHtml}
subagents={unorderedSubagents} subagents={unorderedSubagents}
@@ -923,6 +946,7 @@ function ChatMessageRowView({
<small>{reference.locator}</small> <small>{reference.locator}</small>
)} )}
<p>{reference.snippet}</p> <p>{reference.snippet}</p>
{reference.external && <small>{reference.libraryName} · {reference.external.provider} · {reference.sourceName}</small>}
{reference.retrievalChannels && ( {reference.retrievalChannels && (
<small> <small>
{t('chat.citations.retrieval')} {t('chat.citations.retrieval')}
@@ -956,16 +980,16 @@ function ChatMessageRowView({
> >
{t('chat.citations.viewContext')} {t('chat.citations.viewContext')}
</button> </button>
<button {!reference.external && <button
className="secondary-button" className="secondary-button"
disabled={!reference.chunkId} disabled={!reference.chunkId || !reference.documentId}
onClick={() => onClick={() =>
void onOpenCitationSource(reference) void onOpenCitationSource(reference)
} }
type="button" type="button"
> >
{t('chat.citations.openSource')} {t('chat.citations.openSource')}
</button> </button>}
</div> </div>
</li> </li>
) )
@@ -1061,25 +1085,29 @@ function ChatMessageRowView({
)} )}
</div> </div>
)} )}
{message.question && ( {question && (
<AgentQuestionCard <AgentQuestionCard
key={message.question.questionId} id={questionFormId}
pendingCount={message.pendingQuestions?.length}
taskTitle={questionTask?.reason ??
(questionTask && 'expertName' in questionTask ? questionTask.expertName : undefined) ?? message.task?.title}
key={question.questionId}
onReject={() => onReject={() =>
onRespondQuestion( onRespondQuestion(
conversationId, conversationId,
message.id, message.id,
message.question!.questionId question.questionId
) )
} }
onSubmit={(answers) => onSubmit={(answers) =>
onRespondQuestion( onRespondQuestion(
conversationId, conversationId,
message.id, message.id,
message.question!.questionId, question.questionId,
answers answers
) )
} }
value={message.question} value={question}
/> />
)} )}
{message.answeredQuestions?.map((review) => ( {message.answeredQuestions?.map((review) => (
+328
View File
@@ -0,0 +1,328 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopApi, KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeCatalogPage } from '../../shared/external-knowledge-contracts'
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderDefaults } from './ExternalKnowledge'
import i18n from './i18n'
const instance: ExternalKnowledgeInstanceSummary = { id: 'instance-1', name: 'Company knowledge', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 0 }
const library: KnowledgeLibrary = { id: 'external-1', name: 'Handbook', description: '', storageMode: 'managed', graphEnabled: false, graphStrategy: 'model', sourceCount: 0, documentCount: 0, indexedDocumentCount: 0, external: { knowledgeBaseId: 'external-1', instanceId: instance.id, provider: 'dify', remoteKnowledgeBaseId: 'remote-1', remoteName: 'Handbook', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: externalProviderDefaults('dify'), lastVerifiedAt: '2026-09-12' } }
const snapshot: KnowledgeSnapshot = { libraries: [library], sources: [], documents: [], graphNodes: [], graphRelations: [], evidence: [] }
const bridge = {
externalCatalogList: vi.fn(async (): Promise<ExternalKnowledgeCatalogPage> => ({ items: [{ id: 'remote-1', name: 'Handbook' }], hasMore: false })),
externalCatalogGet: vi.fn(async () => ({ id: 'remote-1', name: 'Handbook' })),
externalRetrievalTest: vi.fn(async () => ({ results: [], durationMs: 10 })),
externalBindingsCreate: vi.fn(async () => snapshot),
externalBindingsUpdate: vi.fn(async () => snapshot),
externalInstancesSave: vi.fn(async () => instance),
externalInstancesTest: vi.fn(async () => instance),
externalInstancesDelete: vi.fn(async () => undefined),
deleteLibrary: vi.fn<(id: string) => Promise<KnowledgeSnapshot>>(async () => snapshot)
}
beforeEach(async () => {
vi.clearAllMocks()
await i18n.changeLanguage('en-US')
window.goodbuddy = { knowledge: bridge } as unknown as DesktopApi
})
afterEach(() => { cleanup(); void i18n.changeLanguage('zh-CN') })
function selectInstance(index = 0): void {
fireEvent.click(screen.getByRole('button', { name: 'External instance' }))
fireEvent.click(screen.getAllByRole('menuitemradio')[index]!)
}
describe('external knowledge workflows', () => {
it('navigates the rich instance menu, skips disabled items and restores focus', async () => {
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'disabled', name: 'Disabled service', enabled: false }, { ...instance, id: 'other', name: 'Other service' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'External instance' })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
const menu = screen.getByRole('menu')
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus())
expect(screen.getByRole('menuitemradio', { name: /Disabled service/ })).toBeDisabled()
fireEvent.keyDown(menu, { key: 'ArrowDown' })
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus()
fireEvent.keyDown(menu, { key: 'Home' })
expect(screen.getByRole('menuitemradio', { name: /Company knowledge/ })).toHaveFocus()
fireEvent.keyDown(menu, { key: 'End' })
fireEvent.click(screen.getByRole('menuitemradio', { name: /Other service/ }))
expect(trigger).toHaveFocus()
fireEvent.click(trigger)
await waitFor(() => expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveFocus())
expect(screen.getByRole('menuitemradio', { name: /Other service/ })).toHaveAttribute('aria-checked', 'true')
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
expect(trigger).toHaveFocus()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('offers clear search and manual entry for distinct empty catalog states', async () => {
bridge.externalCatalogList.mockResolvedValueOnce({ items: [], hasMore: false }).mockResolvedValueOnce({ items: [], hasMore: false })
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
expect((await screen.findByText('No accessible libraries in this instance')).closest('.empty-state')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Search remote libraries'), { target: { value: 'missing' } })
expect((await screen.findByText('No matching results')).closest('.empty-state')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.getByLabelText('Search remote libraries')).toHaveValue('')
await screen.findByRole('button', { name: /Handbook/ })
})
it('can dismiss an instance menu when every instance is disabled', () => {
render(<ExternalBindingForm provider="dify" instances={[{ ...instance, enabled: false }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
const trigger = screen.getByRole('button', { name: 'External instance' })
trigger.focus()
fireEvent.click(trigger)
expect(screen.getByRole('menuitemradio')).toBeDisabled()
fireEvent.keyDown(trigger, { key: 'Escape' })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('labels saving separately from testing and formats verification timestamps', async () => {
let finish!: (value: KnowledgeSnapshot) => void
bridge.externalBindingsUpdate.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
render(<ExternalLibraryDetail library={library} instances={[instance]} libraries={[library]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
expect(document.querySelector('time')).toHaveAttribute('datetime', '2026-09-12')
expect(document.querySelector('time')).not.toHaveTextContent('2026-09-12')
fireEvent.click(screen.getByRole('tab', { name: 'Retrieval settings' }))
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Testing…' })).not.toBeInTheDocument()
await act(async () => finish(snapshot))
})
it('keeps external tabs and filters from shrinking in the scrolling workspace', () => {
const css = readFileSync('src/renderer/src/external-knowledge.css', 'utf8')
expect(css).toMatch(/\.external-knowledge > \.page-tabs\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge__filters\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.external-knowledge-modal__surface\s*\{[^}]*width: min\(560px, 100%\)/)
expect(css).toMatch(/\.external-knowledge__footer\s*\{[^}]*position: sticky/)
expect(css).toMatch(/\.external-knowledge \.external-knowledge\s*\{[^}]*overflow: visible/)
expect(css).not.toMatch(/\.external-knowledge__catalog\s*\{[^}]*overflow: auto/)
expect(css).toContain(".external-knowledge__target:hover:not(:disabled):not([aria-pressed='true'])")
expect(css).toMatch(/\.external-knowledge__fieldset :is\(input, select, textarea, summary\)\s*\{[^}]*scroll-margin-block:/)
})
it('uses shared fields in the portal with one create credential and an adjacent visibility button', async () => {
const view = render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
const dialog = screen.getByRole('dialog', { name: 'Add instance' })
expect(view.container).not.toContainElement(dialog)
expect(screen.getByLabelText('Name').closest('label')).toHaveClass('field')
expect(screen.getByLabelText('Provider').closest('label')).toHaveClass('field')
expect(screen.queryByLabelText('Saved credential')).not.toBeInTheDocument()
const key = screen.getByLabelText('API Key')
expect(key).toHaveAttribute('type', 'password')
const reveal = screen.getByRole('button', { name: 'Show credential' })
expect(key.parentElement).toContainElement(reveal)
fireEvent.click(reveal)
expect(key).toHaveAttribute('type', 'text')
fireEvent.click(screen.getByRole('button', { name: 'Hide credential' }))
expect(key).toHaveAttribute('type', 'password')
const enabled = screen.getByRole('switch')
expect(enabled.previousElementSibling).toHaveTextContent('Enable instance')
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
expect(screen.queryByText(/without transport encryption/)).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'http://knowledge.example' } })
expect(screen.getByLabelText('Service URL')).toHaveAccessibleDescription('This HTTP address sends credentials and queries without transport encryption.')
expect(screen.getByText('How to test the connection').closest('details')).not.toHaveAttribute('open')
})
it('blocks duplicate manual IDs before sending a retrieval request', async () => {
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: ' remote-1 ' } })
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
expect(await screen.findByRole('alert')).toHaveTextContent('Already added')
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
expect(bridge.externalRetrievalTest).not.toHaveBeenCalled()
})
it('discards earlier success when a retest fails or the instance changes', async () => {
const props = { provider: 'dify' as const, instances: [instance], libraries: [], onCancel: vi.fn(), onChanged: vi.fn(), notify: vi.fn(), onManage: vi.fn() }
const view = render(<ExternalBindingForm {...props} />)
selectInstance()
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Remote timeout'))
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Remote timeout')
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
view.rerender(<ExternalBindingForm {...props} instances={[{ ...instance, baseUrl: 'https://other.example' }]} />)
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
})
it('rebinds a manual target without retaining the previous remote name', async () => {
bridge.externalCatalogGet.mockRejectedValueOnce(new Error('No detail endpoint')).mockRejectedValueOnce(new Error('No detail endpoint'))
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'new-target' } })
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'New target name' } })
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await screen.findByText(/Retrieval succeeded with 0 snippets/)
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Save retrieval settings' }))
await waitFor(() => expect(bridge.externalBindingsUpdate).toHaveBeenCalledWith(expect.objectContaining({ knowledgeBaseId: library.id, remoteKnowledgeBaseId: 'new-target', remoteName: 'New target name' })))
})
it('allows disabling saved RAGFlow capabilities that are no longer available', async () => {
const ragLibrary = { ...library, external: { ...library.external!, provider: 'ragflow' as const, providerConfig: { provider: 'ragflow' as const, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: true, includeKnowledgeCompilation: true } } }
render(<ExternalBindingForm provider="ragflow" instances={[{ ...instance, provider: 'ragflow' }]} libraries={[ragLibrary]} library={ragLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
fireEvent.click(screen.getByRole('switch', { name: 'Graph retrieval' }))
fireEvent.click(screen.getByRole('switch', { name: 'Include Knowledge Compilation' }))
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).not.toBeChecked()
expect(screen.getByRole('switch', { name: 'Graph retrieval' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ useKg: false, includeKnowledgeCompilation: false }) })))
})
it('clears incomplete hidden Dify reranking fields when reranking is disabled', async () => {
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[library]} library={library} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
fireEvent.click(screen.getByRole('switch', { name: 'Use dataset defaults' }))
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
fireEvent.change(screen.getByLabelText('Reranking provider'), { target: { value: 'provider' } })
fireEvent.click(screen.getByRole('switch', { name: 'Enable Rerank' }))
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ retrievalModel: expect.objectContaining({ reranking_enable: false, reranking_model: undefined }) }) })))
})
it('requires explicit credential clearing and retains the saved credential by default', async () => {
render(<ExternalInstanceManager instances={[instance]} libraries={[library]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByLabelText('Saved credential')).toHaveValue('keep')
expect(screen.queryByLabelText('API Key')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'replace' } })
expect(screen.getByLabelText('API Key')).toHaveValue('')
fireEvent.change(screen.getByLabelText('Saved credential'), { target: { value: 'clear' } })
expect(screen.getByRole('button', { name: 'Save instance' })).toBeDisabled()
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
await waitFor(() => expect(bridge.externalInstancesSave).toHaveBeenCalledWith(expect.objectContaining({ id: instance.id, credential: { action: 'clear' } })))
})
it('accepts the FastGPT API token range and restores defaults only in the draft', async () => {
const fastLibrary = { ...library, external: { ...library.external!, provider: 'fastgpt' as const, providerConfig: externalProviderDefaults('fastgpt') } }
render(<ExternalBindingForm provider="fastgpt" instances={[{ ...instance, provider: 'fastgpt' }]} libraries={[fastLibrary]} library={fastLibrary} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Token limit'), { target: { value: '30000' } })
expect(screen.getByLabelText('Token limit')).toBeValid()
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await screen.findByText(/Retrieval succeeded with 0 snippets/)
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ providerConfig: expect.objectContaining({ tokenLimit: 30000 }) }))
fireEvent.click(screen.getByRole('button', { name: 'Restore defaults' }))
expect(screen.getByLabelText('Token limit')).toHaveValue(5000)
expect(screen.getByRole('button', { name: 'Save retrieval settings' })).toBeDisabled()
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
expect(bridge.externalInstancesSave).not.toHaveBeenCalled()
expect(screen.getByText(/Settings are saved only in GoodBuddy/)).toBeInTheDocument()
})
it('refreshes completed removals after a later binding deletion fails', async () => {
const onChanged = vi.fn()
bridge.deleteLibrary.mockResolvedValueOnce(snapshot).mockRejectedValueOnce(new Error('Binding removal failed'))
render(<ExternalInstanceManager instances={[{ ...instance, bindingCount: 2 }]} libraries={[library, { ...library, id: 'external-2', name: 'Second binding' }]} onClose={vi.fn()} onChanged={onChanged} notify={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.getByRole('dialog')).toHaveAccessibleName('Delete instance “Company knowledge”?')
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toHaveClass('danger-button')
expect(screen.getByText('Handbook')).toBeInTheDocument()
expect(screen.getByText('Second binding')).toBeInTheDocument()
expect(within(screen.getByRole('dialog')).getAllByRole('button', { name: 'Cancel' }).find(button => button.textContent === 'Cancel')).toHaveFocus()
fireEvent.click(screen.getByRole('button', { name: 'Delete instance and local bindings' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Binding removal failed')
expect(onChanged).toHaveBeenCalledOnce()
expect(bridge.externalInstancesDelete).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Delete instance and local bindings' })).toBeEnabled()
})
it('prevents retrieval through disabled instances', () => {
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[{ ...instance, enabled: false }]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'query' } })
expect(screen.getByRole('button', { name: 'Test retrieval' })).toBeDisabled()
expect(screen.getByRole('status')).toHaveTextContent('Instance disabled')
})
it.each(['dify', 'fastgpt', 'ragflow'] as const)('creates a %s binding after a zero-result retrieval', async provider => {
const onChanged = vi.fn()
render(<ExternalBindingForm provider={provider} instances={[{ ...instance, provider }]} libraries={[]} onCancel={vi.fn()} onChanged={onChanged} notify={vi.fn()} onManage={vi.fn()} />)
for (const control of document.querySelectorAll('input:not([type="checkbox"]), select, textarea')) {
expect(control.closest('label')).toHaveClass('field')
}
for (const toggle of screen.getAllByRole('switch')) {
expect(toggle.previousElementSibling?.tagName).toBe('SPAN')
expect(toggle.closest('label')).toHaveClass('toggle-row')
}
selectInstance()
fireEvent.click(await screen.findByRole('button', { name: /Handbook/ }))
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'network policy' } })
expect(screen.getByRole('button', { name: 'Add library' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Add library' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Add library' }))
await waitFor(() => expect(bridge.externalBindingsCreate).toHaveBeenCalledWith(expect.objectContaining({ instanceId: instance.id, remoteKnowledgeBaseId: 'remote-1', testQuery: 'network policy', providerConfig: expect.objectContaining({ provider }) })))
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(snapshot, library.id))
})
it('retains a manual target and query after retrieval fails', async () => {
bridge.externalRetrievalTest.mockRejectedValueOnce(new Error('Authentication failed'))
render(<ExternalBindingForm provider="dify" instances={[instance]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
fireEvent.click(screen.getByRole('button', { name: 'Enter library ID manually' }))
fireEvent.change(screen.getByLabelText('Remote library ID'), { target: { value: 'manual-id' } })
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'keep this query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Authentication failed')
expect(screen.getByLabelText('Remote library ID')).toHaveValue('manual-id')
expect(screen.getByLabelText('Test query')).toHaveValue('keep this query')
})
it('ignores an old catalog when the instance changes', async () => {
let resolve!: (page: ExternalKnowledgeCatalogPage) => void
bridge.externalCatalogList.mockImplementationOnce(() => new Promise(done => { resolve = done }))
render(<ExternalBindingForm provider="dify" instances={[instance, { ...instance, id: 'instance-2' }]} libraries={[]} onCancel={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} />)
selectInstance()
await waitFor(() => expect(bridge.externalCatalogList).toHaveBeenCalledOnce())
selectInstance(1)
await screen.findByRole('button', { name: /Handbook/ })
await act(async () => resolve({ items: [{ id: 'old', name: 'Stale catalog' }], hasMore: false }))
expect(screen.queryByText('Stale catalog')).not.toBeInTheDocument()
})
it('tests saved configuration without exposing save or target editing in the test tab', async () => {
render(<ExternalLibraryDetail library={library} libraries={[library]} instances={[instance]} onChanged={vi.fn()} notify={vi.fn()} onManage={vi.fn()} onUseInChat={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: 'Test retrieval' }))
expect(screen.queryByRole('button', { name: 'Save retrieval settings' })).not.toBeInTheDocument()
expect(screen.queryByLabelText('External instance')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Test query'), { target: { value: 'saved config query' } })
fireEvent.click(screen.getByRole('button', { name: 'Test retrieval' }))
await screen.findByText(/Retrieval succeeded with 0 snippets/)
expect(bridge.externalBindingsUpdate).not.toHaveBeenCalled()
expect(bridge.externalRetrievalTest).toHaveBeenCalledWith(expect.objectContaining({ commonConfig: library.external!.commonConfig, providerConfig: library.external!.providerConfig }))
})
it('saves an instance without an all-address consent gate and preserves failed drafts', async () => {
bridge.externalInstancesSave.mockRejectedValueOnce(new Error('Save failed'))
render(<ExternalInstanceManager instances={[]} libraries={[]} onClose={vi.fn()} onChanged={vi.fn()} notify={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Add instance' }))
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Company' } })
fireEvent.change(screen.getByLabelText('Service URL'), { target: { value: 'https://knowledge.example' } })
fireEvent.change(screen.getAllByLabelText('API Key').find(element => element.tagName === 'INPUT')!, { target: { value: 'test-key' } })
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Save instance' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Save instance' })).toBeEnabled())
expect(screen.getByLabelText('Service URL')).toHaveValue('https://knowledge.example')
})
})
+467
View File
@@ -0,0 +1,467 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { ChevronDown, Database, Eye, EyeOff, FolderOpen, RefreshCw, X } from 'lucide-react'
import type { KnowledgeLibrary, KnowledgeSnapshot } from '../../shared/contracts'
import {
externalKnowledgeBindingTestInputSchema,
externalKnowledgeInstanceSaveInputSchema,
type ExternalKnowledgeProvider,
type ExternalKnowledgeProviderConfig,
type ExternalKnowledgeCommonConfig,
type ExternalKnowledgeCatalogItem,
type ExternalKnowledgeCatalogPage,
type ExternalKnowledgeInstanceSaveInput,
type ExternalKnowledgeInstanceSummary,
type ExternalKnowledgeTestResult
} from '../../shared/external-knowledge-contracts'
import { EmptyState, PageTabs, ScopeBadge, SegmentedControl } from './WorkspacePrimitives'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications'
import './external-knowledge.css'
export const externalProviderNames = { dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' }
export function externalLibraryStatus(library: KnowledgeLibrary, instances: ExternalKnowledgeInstanceSummary[]): 'ready' | 'untested' | 'instance-disabled' | 'credential-error' | 'temporarily-unavailable' {
const instance = instances.find(item => item.id === library.external?.instanceId)
if (!instance) return 'temporarily-unavailable'
if (!instance.enabled) return 'instance-disabled'
if (instance.credentialStatus !== 'configured' || instance.probeStatus === 'auth-failed') return 'credential-error'
if (['failed', 'unreachable'].includes(instance.probeStatus)) return 'temporarily-unavailable'
return library.external?.lastVerifiedAt ? 'ready' : 'untested'
}
type Notify = (input: AppNotificationInput) => void
type Changed = (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
const commonDefaults: ExternalKnowledgeCommonConfig = { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }
export function externalProviderDefaults(provider: ExternalKnowledgeProvider): ExternalKnowledgeProviderConfig {
if (provider === 'dify') return { provider, useDatasetDefaults: true }
if (provider === 'fastgpt') return { provider, searchMode: 'embedding', tokenLimit: 5000, similarity: 0, usingRerank: false }
return { provider, similarityThreshold: 0.2, vectorSimilarityWeight: 0.3, knnTopK: 1024, useKg: false, includeKnowledgeCompilation: false }
}
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
function Field({ label, children }: { label: string; children: ReactNode }): React.JSX.Element {
return <label className="field"><span>{label}</span>{children}</label>
}
function Switch({ label, checked, disabled, onChange }: { label: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void }): React.JSX.Element {
return <label className="toggle-row"><span>{label}</span><input type="checkbox" role="switch" checked={checked} disabled={disabled} onChange={event => onChange(event.currentTarget.checked)} /></label>
}
function InstanceSelect({ instances, value, onChange }: { instances: ExternalKnowledgeInstanceSummary[]; value: string; onChange: (value: string) => void }): React.JSX.Element {
const { t } = useTranslation('knowledge')
const [open, setOpen] = useState(false)
const root = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const menu = useRef<HTMLDivElement>(null)
const id = useId()
useEffect(() => {
if (!open) return
const frame = requestAnimationFrame(() => {
const items = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])
const selected = items.find(item => item.getAttribute('aria-checked') === 'true') ?? items[0]
items.forEach(item => { item.tabIndex = item === selected ? 0 : -1 })
selected?.focus()
})
const dismiss = (event: Event): void => { if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false) }
document.addEventListener('pointerdown', dismiss)
document.addEventListener('focusin', dismiss)
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', dismiss); document.removeEventListener('focusin', dismiss) }
}, [open])
return <div className="field external-knowledge__instance-select" ref={root}>
<span id={`${id}-label`}>{t('external.instance')}</span>
<button type="button" className="model-button" ref={trigger} aria-labelledby={`${id}-label`} aria-describedby={`${id}-value`} aria-expanded={open} aria-haspopup="menu" aria-controls={open ? id : undefined} onClick={() => setOpen(!open)} onKeyDown={event => {
if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); setOpen(true) }
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); setOpen(false) }
}}><span className="model-button__label" id={`${id}-value`}>{instances.find(item => item.id === value)?.name ?? t('external.selectInstance')}</span><ChevronDown size={14} aria-hidden="true" /></button>
{open && <div className="runtime-picker__menu composer-picker__menu" role="menu" aria-labelledby={`${id}-label`} id={id} ref={menu} onKeyDown={event => {
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === 'ArrowDown' ? (index + 1) % items.length : event.key === 'ArrowUp' ? (index - 1 + items.length) % items.length : undefined
if (next !== undefined && items[next]) { event.preventDefault(); items.forEach((item, i) => { item.tabIndex = i === next ? 0 : -1 }); items[next].focus() }
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus() }
}}>
{!instances.length && <p className="external-knowledge__help">{t('external.noInstancesHelp')}</p>}
{instances.map(item => <button type="button" role="menuitemradio" key={item.id} aria-checked={item.id === value} disabled={!item.enabled || item.credentialStatus !== 'configured'} tabIndex={-1} onClick={() => { onChange(item.id); setOpen(false); trigger.current?.focus() }}>
<span>{item.name}</span><small>{new URL(item.baseUrl).origin} · {item.enabled ? t(`external.credentials.${item.credentialStatus}`) : t('external.states.instance-disabled')}</small>
</button>)}
</div>}
</div>
}
function ExternalTime({ value }: { value: string }): React.JSX.Element {
const { i18n, t } = useTranslation('knowledge')
const date = new Date(value)
if (Number.isNaN(date.getTime())) return <>{t('format.unknownTime')}</>
const label = date.toLocaleString(i18n.resolvedLanguage, { dateStyle: 'medium', timeStyle: 'short' })
return <time dateTime={value} title={date.toLocaleString(i18n.resolvedLanguage)}>{label}</time>
}
export function ExternalConfig({ common, config, detail, onCommon, onConfig }: {
common: ExternalKnowledgeCommonConfig; config: ExternalKnowledgeProviderConfig; detail?: ExternalKnowledgeCatalogItem
onCommon: (value: ExternalKnowledgeCommonConfig) => void; onConfig: (value: ExternalKnowledgeProviderConfig) => void
}): React.JSX.Element {
const { t } = useTranslation('knowledge')
const number = (label: string, value: number, min: number, max: number, change: (value: number) => void, step = 1): ReactNode =>
<Field label={label}><input type="number" required min={min} max={max} step={step} value={value} onChange={event => change(event.currentTarget.valueAsNumber)} /></Field>
const text = (label: string, value: string, change: (value: string) => void): ReactNode =>
<Field label={label}><input required value={value} onChange={event => change(event.currentTarget.value)} /></Field>
return <section className="external-knowledge__fields">
<fieldset className="external-knowledge__group">
<legend>{t('external.common')}</legend>
{number(t('external.resultLimit'), common.resultLimit, 1, 20, value => onCommon({ ...common, resultLimit: value }))}
{number(t('external.timeout'), common.requestTimeoutMs, 1000, 60000, value => onCommon({ ...common, requestTimeoutMs: value }), 1000)}
{number(t('external.snippetLimit'), common.maxSnippetCharacters, 100, 8000, value => onCommon({ ...common, maxSnippetCharacters: value }))}
</fieldset>
<fieldset className="external-knowledge__group">
<legend>{externalProviderNames[config.provider]} · {t('external.config')}</legend>
{config.provider === 'dify' && <>
<Switch label={t('external.datasetDefaults')} checked={config.useDatasetDefaults} onChange={value => onConfig(value ? { provider: 'dify', useDatasetDefaults: true } : { provider: 'dify', useDatasetDefaults: false, retrievalModel: { search_method: 'semantic_search', top_k: 6, reranking_enable: false, score_threshold_enabled: false } })} />
{config.useDatasetDefaults ? <p className="knowledge-muted">{t('external.datasetDefaultsHelp')}</p> : (() => {
const model = config.retrievalModel
const update = (value: Partial<typeof model>): void => onConfig({ ...config, retrievalModel: { ...model, ...value } })
return <>
<Field label={t('external.searchMethod')}><select value={model.search_method} onChange={event => update({ search_method: event.currentTarget.value as typeof model.search_method })}>{['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search'].map(value => <option key={value}>{value}</option>)}</select></Field>
{number(t('external.topK'), model.top_k, 1, 20, value => update({ top_k: value }))}
<Switch label={t('external.thresholdEnabled')} checked={model.score_threshold_enabled} onChange={value => update({ score_threshold_enabled: value, score_threshold: model.score_threshold ?? 0.5 })} />
{model.score_threshold_enabled && number(t('external.threshold'), model.score_threshold ?? 0.5, 0, 1, value => update({ score_threshold: value }), 0.01)}
<Switch label={t('external.rerank')} checked={model.reranking_enable} onChange={value => update(value ? { reranking_enable: true, reranking_mode: model.reranking_mode ?? 'reranking_model' } : { reranking_enable: false, reranking_mode: undefined, reranking_model: undefined, weights: undefined })} />
{model.reranking_enable && <>
<Field label={t('external.rerankMode')}><select value={model.reranking_mode ?? 'reranking_model'} onChange={event => update({ reranking_mode: event.currentTarget.value as 'weighted_score' | 'reranking_model', reranking_model: undefined, weights: undefined })}><option value="reranking_model">{t('external.rerankModel')}</option><option value="weighted_score">{t('external.weightedScore')}</option></select></Field>
{model.reranking_mode !== 'weighted_score' ? <>
{text(t('external.rerankProvider'), model.reranking_model?.reranking_provider_name ?? '', value => update({ reranking_model: { reranking_provider_name: value, reranking_model_name: model.reranking_model?.reranking_model_name ?? '' } }))}
{text(t('external.rerankModel'), model.reranking_model?.reranking_model_name ?? '', value => update({ reranking_model: { reranking_provider_name: model.reranking_model?.reranking_provider_name ?? '', reranking_model_name: value } }))}
</> : (() => {
const weights = model.weights ?? { weight_type: 'customized' as const, vector_setting: { vector_weight: 0.5, embedding_provider_name: '', embedding_model_name: '' }, keyword_setting: { keyword_weight: 0.5 } }
return <>
{number(t('external.vectorWeight'), weights.vector_setting.vector_weight, 0, 1, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, vector_weight: value }, keyword_setting: { keyword_weight: 1 - value } } }), 0.01)}
<p>{t('external.keywordWeight')}: {Number(weights.keyword_setting.keyword_weight.toFixed(2))}</p>
{text(t('external.embeddingProvider'), weights.vector_setting.embedding_provider_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_provider_name: value } } }))}
{text(t('external.embeddingModel'), weights.vector_setting.embedding_model_name, value => update({ weights: { ...weights, vector_setting: { ...weights.vector_setting, embedding_model_name: value } } }))}
</>
})()}
</>}
</>
})()}
</>}
{config.provider === 'fastgpt' && <>
<Field label={t('external.searchMethod')}><select value={config.searchMode} onChange={event => onConfig({ ...config, searchMode: event.currentTarget.value as typeof config.searchMode })}>{['embedding', 'fullTextRecall', 'mixedRecall'].map(value => <option key={value}>{value}</option>)}</select></Field>
{number(t('external.tokenLimit'), config.tokenLimit, 1, 30000, value => onConfig({ ...config, tokenLimit: value }))}
{number(t('external.threshold'), config.similarity, 0, 1, value => onConfig({ ...config, similarity: value }), 0.01)}
<Switch label={t('external.rerank')} checked={config.usingRerank} onChange={value => onConfig({ ...config, usingRerank: value })} />
</>}
{config.provider === 'ragflow' && <>
{number(t('external.threshold'), config.similarityThreshold, 0, 1, value => onConfig({ ...config, similarityThreshold: value }), 0.01)}
{number(t('external.vectorWeight'), config.vectorSimilarityWeight, 0, 1, value => onConfig({ ...config, vectorSimilarityWeight: value }), 0.01)}
{number(t('external.knnTopK'), config.knnTopK, 1, 2048, value => onConfig({ ...config, knnTopK: value }))}
<Switch label={t('external.graph')} checked={config.useKg} disabled={!config.useKg && !detail?.graphEnabled} onChange={value => onConfig({ ...config, useKg: value })} />
<p className="knowledge-muted">{t(detail?.graphEnabled ? 'external.graphHelp' : 'external.graphUnknown')}</p>
<Switch label={t('external.compilation')} checked={config.includeKnowledgeCompilation} disabled={!config.includeKnowledgeCompilation && !detail?.knowledgeCompilationEnabled} onChange={value => onConfig({ ...config, includeKnowledgeCompilation: value })} />
{!detail?.knowledgeCompilationEnabled && <p className="knowledge-muted">{t('external.compilationUnavailable')}</p>}
</>}
</fieldset>
<p className="external-knowledge__help">{t('external.localOnly')}</p>
<button className="secondary-button" type="button" onClick={() => { onCommon(commonDefaults); onConfig(externalProviderDefaults(config.provider)) }}>{t('external.defaults')}</button>
</section>
}
function TestResults({ result }: { result: ExternalKnowledgeTestResult }): React.JSX.Element {
const { t } = useTranslation('knowledge')
return <section className="external-knowledge__results" aria-live="polite">
<p>{t('external.tested', { count: result.results.length })} · {t('external.elapsed', { ms: result.durationMs })}</p>
<ol>{result.results.map((item, index) => <li key={`${item.remoteChunkId ?? item.remoteDocumentId ?? index}:${index}`}>
<strong>{item.documentTitle}</strong><small>{item.sourceDisplayName} {item.location}</small>
<p>{item.snippet}</p>
{item.providerScore !== undefined && <small>{t('external.providerScore')}: {item.providerScore}</small>}
{item.providerScores?.map((score, scoreIndex) => <small key={scoreIndex}>{t('external.providerScore')} · {score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</small>)}
{item.remoteDocumentId && <small>{t('external.documentId')}: {item.remoteDocumentId}</small>}
{item.remoteChunkId && <small>{t('external.chunkId')}: {item.remoteChunkId}</small>}
</li>)}</ol>
</section>
}
export function ExternalBindingForm({ provider, instances, libraries, library, onCancel, onChanged, notify, onManage }: {
provider: ExternalKnowledgeProvider; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; library?: KnowledgeLibrary
onCancel: () => void; onChanged: Changed; notify: Notify; onManage: () => void
}): React.JSX.Element {
const { t } = useTranslation('knowledge')
const binding = library?.external
const [instanceId, setInstanceId] = useState(binding?.instanceId ?? '')
const [remoteId, setRemoteId] = useState(binding?.remoteKnowledgeBaseId ?? '')
const [name, setName] = useState(library?.name ?? '')
const [detail, setDetail] = useState<ExternalKnowledgeCatalogItem>()
const [common, setCommon] = useState(binding?.commonConfig ?? commonDefaults)
const [config, setConfig] = useState(binding?.providerConfig ?? externalProviderDefaults(provider))
const [manual, setManual] = useState(false)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [parentId, setParentId] = useState<string | null>(null)
const [catalog, setCatalog] = useState<ExternalKnowledgeCatalogPage>()
const [catalogError, setCatalogError] = useState<string>()
const [catalogLoading, setCatalogLoading] = useState(false)
const [refresh, setRefresh] = useState(0)
const [query, setQuery] = useState('')
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
const [testedInstance, setTestedInstance] = useState<string>()
const [rebindConfirmed, setRebindConfirmed] = useState(false)
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [operationError, setOperationError] = useState<string>()
const generation = useRef(0)
const errorId = useId()
const activeInstance = instances.find(item => item.id === instanceId)
const instanceVersion = JSON.stringify(activeInstance)
const validInstance = activeInstance?.provider === provider && activeInstance.enabled && activeInstance.credentialStatus === 'configured'
const verifiedResult = testedInstance === instanceVersion ? result : undefined
const duplicate = libraries.some(item => item.id !== library?.id && item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
const invalidate = (): void => { generation.current++; setResult(undefined); setOperationError(undefined); setBusy(false); setRebindConfirmed(false) }
useEffect(() => () => { generation.current++ }, [])
useEffect(() => {
let active = true
void Promise.resolve().then(async () => {
if (!active) return
setCatalog(undefined); setCatalogError(undefined)
if (!instanceId || !validInstance || manual) { setCatalogLoading(false); return }
setCatalogLoading(true)
const value = await window.goodbuddy.knowledge.externalCatalogList({ instanceId, page, pageSize: 30, parentId, search })
if (active) setCatalog(value)
}).catch(error => { if (active) setCatalogError(errorMessage(error)) }).finally(() => { if (active) setCatalogLoading(false) })
return () => { active = false }
}, [instanceId, validInstance, instanceVersion, page, parentId, search, manual, refresh])
useEffect(() => {
let active = true
if (!validInstance || !remoteId.trim()) return
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId, remoteKnowledgeBaseId: remoteId }).then(value => { if (active) setDetail(value) }).catch(() => { /* Retrieval remains available when a deployment has no detail endpoint. */ })
return () => { active = false }
}, [instanceId, remoteId, validInstance, instanceVersion, refresh])
const testInput = { instanceId, remoteKnowledgeBaseId: remoteId, commonConfig: common, providerConfig: config, testQuery: query }
const valid = validInstance && !duplicate && externalKnowledgeBindingTestInputSchema.safeParse(testInput).success
const rebinding = binding && (binding.instanceId !== instanceId || binding.remoteKnowledgeBaseId !== remoteId.trim())
const remoteName = detail?.name || (!rebinding ? binding?.remoteName : undefined) || name.trim()
const run = async (save: boolean): Promise<void> => {
if (busy || !valid || (save && (!verifiedResult || !name.trim() || (rebinding && !rebindConfirmed)))) return
const current = ++generation.current
setBusy(true)
setSaving(save)
setOperationError(undefined)
try {
if (save) {
const input = { ...testInput, name: name.trim(), remoteName, description: library?.description }
const snapshot = library
? await window.goodbuddy.knowledge.externalBindingsUpdate({ ...input, knowledgeBaseId: library.id })
: await window.goodbuddy.knowledge.externalBindingsCreate(input)
if (current !== generation.current) return
const created = snapshot.libraries.find(item => item.external?.instanceId === instanceId && item.external.remoteKnowledgeBaseId === remoteId.trim())
await onChanged(snapshot, library ? undefined : created?.id)
notify({ tone: 'success', message: t('external.saved') }); onCancel()
} else {
setResult(undefined)
const value = await window.goodbuddy.knowledge.externalRetrievalTest(testInput)
if (current === generation.current) { setResult(value); setTestedInstance(instanceVersion) }
}
} catch (error) { if (current === generation.current) setOperationError(errorMessage(error)) }
finally { if (current === generation.current) setBusy(false) }
}
return <form className="external-knowledge" onSubmit={event => { event.preventDefault(); void run(true) }}>
<fieldset disabled={busy} className="external-knowledge__fieldset" aria-describedby={operationError ? errorId : undefined}>
<div className="external-knowledge__columns">
<section className="external-knowledge__fields">
<h3>{t('external.source')}</h3>
<InstanceSelect instances={instances.filter(item => item.provider === provider)} value={instanceId} onChange={value => { invalidate(); setDetail(undefined); setInstanceId(value); setRemoteId(''); setConfig(externalProviderDefaults(provider)); setPage(1); setParentId(null); setSearch('') }} />
<button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button>
<SegmentedControl ariaLabel={t('external.remote')} value={manual ? 'manual' : 'catalog'} options={[{ value: 'catalog', label: t('external.browse') }, { value: 'manual', label: t('external.manual') }]} onChange={value => setManual(value === 'manual')} />
{manual ? <Field label={t('external.remoteId')}><input maxLength={512} value={remoteId} disabled={busy} aria-invalid={duplicate || undefined} aria-describedby={duplicate ? `${errorId}-duplicate` : undefined} onChange={event => { invalidate(); setDetail(undefined); setRemoteId(event.currentTarget.value) }} /></Field> : <>
<Field label={t('external.search')}><input type="search" maxLength={512} value={search} onChange={event => { setSearch(event.currentTarget.value); setPage(1) }} /></Field>
<div className="external-knowledge__actions"><button type="button" className="secondary-button" aria-label={t('external.refresh')} title={t('external.refresh')} onClick={() => { invalidate(); setRefresh(value => value + 1) }}><RefreshCw size={15} /></button>
{parentId && <button type="button" className="secondary-button" onClick={() => { setParentId(null); setPage(1) }}>{t('external.root')}</button>}</div>
{catalogLoading && <p role="status">{t('external.loading')}</p>}
{catalogError && <div role="alert"><p>{catalogError}</p><button type="button" className="secondary-button" onClick={() => setRefresh(value => value + 1)}>{t('actions.retry')}</button></div>}
{catalog && <div className="external-knowledge__catalog" aria-label={t('external.remote')}>
{catalog.items.length === 0 && <EmptyState icon={<Database size={20} />} title={t(search ? 'external.noResults' : 'external.empty')} description={t(search ? 'external.noResultsHelp' : 'external.emptyHelp')} action={<button type="button" className="secondary-button" onClick={() => { if (search) { setSearch(''); setPage(1) } else setManual(true) }}>{t(search ? 'external.clearSearch' : 'external.manual')}</button>} />}
{catalog.items.map(item => {
const bound = libraries.some(candidate => candidate.id !== library?.id && candidate.external?.instanceId === instanceId && candidate.external.remoteKnowledgeBaseId === item.id)
return <button type="button" className="external-knowledge__target" key={item.id} aria-pressed={item.kind === 'folder' ? undefined : remoteId === item.id} disabled={bound || busy} onClick={() => {
if (item.kind === 'folder') { setParentId(item.id); setPage(1); return }
invalidate(); setRemoteId(item.id); setDetail(item); if (!name) setName(item.name)
}}>{item.kind === 'folder' ? <FolderOpen size={15} /> : <Database size={15} />}<span><strong>{item.name}</strong><small>{item.id}{bound ? ` · ${t('external.alreadyBound')}` : ''}</small></span></button>
})}
<div className="external-knowledge__actions"><button type="button" className="secondary-button" disabled={page === 1} onClick={() => setPage(value => value - 1)}>{t('external.previous')}</button><span>{page}</span><button type="button" className="secondary-button" disabled={!catalog.hasMore} onClick={() => setPage(value => value + 1)}>{t('external.next')}</button></div>
</div>}
</>}
{remoteId && <p className="external-knowledge__identifier">{remoteName}<br />{remoteId}</p>}
{duplicate && <p role="alert" id={`${errorId}-duplicate`}>{t('external.alreadyBound')}</p>}
<Field label={t('fields.name')}><input required maxLength={512} value={name} onChange={event => setName(event.currentTarget.value)} /></Field>
<Field label={t('external.query')}><textarea required maxLength={4000} value={query} onChange={event => { invalidate(); setQuery(event.currentTarget.value) }} /></Field>
</section>
<ExternalConfig common={common} config={config} detail={detail} onCommon={value => { invalidate(); setCommon(value) }} onConfig={value => { invalidate(); setConfig(value) }} />
</div>
<p className="knowledge-muted">{t('external.queryNote')}</p>
{operationError && <p id={errorId} role="alert">{operationError}</p>}
{verifiedResult ? <TestResults result={verifiedResult} /> : <p role="status">{t('external.untested')}</p>}
{rebinding && <label><input type="checkbox" checked={rebindConfirmed} onChange={event => setRebindConfirmed(event.currentTarget.checked)} />{t('external.rebindConfirm')}</label>}
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} />
<button type="button" className="secondary-button" onClick={() => { invalidate(); onCancel() }}>{t('actions.cancel')}</button>
<button type="button" className="secondary-button" disabled={busy || !valid} onClick={() => void run(false)}>{t(busy && !saving ? 'external.testing' : 'external.test')}</button>
<button type="submit" className="primary-button" disabled={busy || !verifiedResult || !valid || !name.trim() || (!!rebinding && !rebindConfirmed)}>{t(busy && saving ? 'actions.saving' : library ? 'external.saveConfig' : 'external.add')}</button>
</footer>
</fieldset>
</form>
}
export function ExternalInstanceManager({ instances, libraries, onClose, onChanged, notify }: {
instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onClose: () => void; onChanged: Changed; notify: Notify
}): React.JSX.Element {
const { t } = useTranslation('knowledge')
const [draft, setDraft] = useState<ExternalKnowledgeInstanceSaveInput>()
const [showKey, setShowKey] = useState(false)
const [deleting, setDeleting] = useState<ExternalKnowledgeInstanceSummary>()
const [busy, setBusy] = useState(false)
const [clearConfirmed, setClearConfirmed] = useState(false)
const [deleteError, setDeleteError] = useState<string>()
const dialogRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const generation = useRef(0)
const keyId = useId()
useEffect(() => activateModalFocus(() => closeRef.current), [])
useEffect(() => () => { generation.current++ }, [])
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
const current = ++generation.current; setBusy(true)
try { await action(); if (current !== generation.current) return; await onChanged(); if (message) notify({ tone: 'success', message }) }
catch (error) { if (current === generation.current) notify({ tone: 'error', message: errorMessage(error) }) }
finally { if (current === generation.current) setBusy(false) }
}
const edit = (instance?: ExternalKnowledgeInstanceSummary): void => {
setDraft(instance ? { id: instance.id, name: instance.name, provider: instance.provider, baseUrl: instance.baseUrl, enabled: instance.enabled, credential: { action: 'keep' } } : { name: '', provider: 'dify', baseUrl: '', enabled: true, credential: { action: 'replace', value: '' } })
setClearConfirmed(false); setShowKey(false)
}
const change = (value: Partial<ExternalKnowledgeInstanceSaveInput>): void => { if (draft) setDraft({ ...draft, ...value }); setClearConfirmed(false) }
return createPortal(<div className="external-knowledge-modal" role="dialog" aria-modal="true" aria-labelledby="external-instance-title" ref={dialogRef} onKeyDown={event => { if (event.key === 'Escape' && !busy) { event.preventDefault(); onClose() }; trapTabFocus(event, dialogRef.current) }}>
<section className="external-knowledge-modal__surface">
<header><div><h2 id="external-instance-title">{deleting ? t('external.deleteInstanceTitle', { name: deleting.name }) : t(draft ? draft.id ? 'external.editInstance' : 'external.addInstance' : 'external.instances')}</h2><ScopeBadge scope={{ kind: 'global' }} /></div><button type="button" className="icon-button" ref={closeRef} disabled={busy} aria-label={t('external.close')} title={t('external.close')} onClick={onClose}><X size={16} /></button></header>
<div className="external-knowledge-modal__body">
{draft ? <form className="external-knowledge__fields" onSubmit={event => { event.preventDefault(); if (busy || (draft.credential.action === 'clear' && !clearConfirmed)) return; void run(async () => { await window.goodbuddy.knowledge.externalInstancesSave(draft); setDraft(undefined) }, t('external.saved')) }}>
<fieldset disabled={busy} className="external-knowledge__fieldset">
<div className="external-knowledge__columns">
<Field label={t('fields.name')}><input autoFocus required value={draft.name} onChange={event => change({ name: event.currentTarget.value })} /></Field>
<Field label={t('external.provider')}><select disabled={!!draft.id && instances.some(item => item.id === draft.id && item.bindingCount > 0)} value={draft.provider} onChange={event => change({ provider: event.currentTarget.value as ExternalKnowledgeProvider })}>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></Field>
</div>
<Field label={t('external.address')}><input type="url" required value={draft.baseUrl} aria-describedby={/^http:\/\//i.test(draft.baseUrl) ? `${keyId}-transport` : undefined} onChange={event => change({ baseUrl: event.currentTarget.value })} /></Field>
{/^http:\/\//i.test(draft.baseUrl) && <p className="external-knowledge__help" id={`${keyId}-transport`}>{t('external.transportNote')}</p>}
<fieldset className="external-knowledge__group">
<legend>{t('external.authentication')}</legend>
{draft.id && <Field label={t('external.credentialAction')}><select value={draft.credential.action} onChange={event => { setShowKey(false); change({ credential: event.currentTarget.value === 'replace' ? { action: 'replace', value: '' } : { action: event.currentTarget.value as 'keep' | 'clear' } }) }}>
{draft.id && <option value="keep">{t('external.keep')}</option>}<option value="replace">{t('external.replace')}</option>{draft.id && <option value="clear">{t('external.clear')}</option>}
</select></Field>}
{draft.credential.action === 'replace' && <div className="field">
<span><label htmlFor={keyId}>{t('external.credential')}</label></span>
<div className="external-knowledge__password"><input id={keyId} type={showKey ? 'text' : 'password'} autoComplete="new-password" required aria-describedby={`${keyId}-note`} value={draft.credential.value} onChange={event => change({ credential: { action: 'replace', value: event.currentTarget.value } })} />
<button type="button" className="icon-button" aria-label={t(showKey ? 'external.hideKey' : 'external.showKey')} title={t(showKey ? 'external.hideKey' : 'external.showKey')} aria-pressed={showKey} onClick={() => setShowKey(value => !value)}>{showKey ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
</div>}
<p className="external-knowledge__help" id={`${keyId}-note`}>{t('external.credentialNote')}</p>
{draft.credential.action === 'clear' && <label><input type="checkbox" checked={clearConfirmed} onChange={event => setClearConfirmed(event.currentTarget.checked)} />{t('external.clearConfirm')}</label>}
</fieldset>
<div className="external-knowledge__fields">
<Switch label={t('external.enabled')} checked={draft.enabled} onChange={value => change({ enabled: value })} />
<p className="external-knowledge__help">{t('external.enabledHelp')}</p>
</div>
<details className="external-knowledge__help"><summary>{t('external.connectionHelp')}</summary><p>{t('external.saveThenTest')}</p></details>
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" disabled={busy} onClick={() => setDraft(undefined)}>{t('actions.cancel')}</button><button type="submit" className="primary-button" disabled={busy || (draft.credential.action === 'clear' && !clearConfirmed) || !URL.canParse(draft.baseUrl) || !externalKnowledgeInstanceSaveInputSchema.safeParse(draft).success}>{t(busy ? 'actions.saving' : 'external.saveInstance')}</button></footer>
</fieldset>
</form> : deleting ? <section className="external-knowledge__fields" aria-labelledby="external-instance-title">
<p>{t('external.deleteInstanceConfirm', { count: libraries.filter(item => item.external?.instanceId === deleting.id).length })}</p>
<ul>{libraries.filter(item => item.external?.instanceId === deleting.id).map(item => <li key={item.id}>{item.name}</li>)}</ul>
{deleteError && <p role="alert" id="external-delete-error">{deleteError}</p>}
<footer className="external-knowledge__actions external-knowledge__footer"><ScopeBadge scope={{ kind: 'global' }} /><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setDeleting(undefined)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} aria-describedby={deleteError ? 'external-delete-error' : undefined} onClick={() => {
setBusy(true); setDeleteError(undefined)
void (async () => {
try {
for (const item of libraries.filter(item => item.external?.instanceId === deleting.id)) await window.goodbuddy.knowledge.deleteLibrary(item.id)
await window.goodbuddy.knowledge.externalInstancesDelete({ instanceId: deleting.id })
setDeleting(undefined); notify({ tone: 'success', message: t('external.removed') })
} catch (error) { setDeleteError(errorMessage(error)) }
finally {
// A failed later deletion must still refresh bindings already removed.
try { await onChanged() } catch (error) { notify({ tone: 'error', message: errorMessage(error) }) }
setBusy(false)
}
})()
}}>{t('external.deleteInstance')}</button></footer>
</section> : <>
<button type="button" className="primary-button" disabled={busy} onClick={() => edit()}>{t('external.addInstance')}</button>
{!instances.length && <EmptyState title={t('external.noInstances')} description={t('external.noInstancesHelp')} icon={<Database size={24} />} />}
<ul className="external-knowledge__instances">{instances.map(instance => <li key={instance.id}>
<div><strong>{instance.name}</strong><p>{externalProviderNames[instance.provider]} · {new URL(instance.baseUrl).origin}</p><small>{instance.enabled ? t(`external.probe.${instance.probeStatus}`) : t('external.states.instance-disabled')} · {t(`external.credentials.${instance.credentialStatus}`)} · {t('external.boundCount', { count: instance.bindingCount })}</small>{instance.lastTestedAt && <small>{t('external.lastTest')}: <ExternalTime value={instance.lastTestedAt} /></small>}</div>
<div className="external-knowledge__actions"><Switch label={`${t('external.enabled')}: ${instance.name}`} checked={instance.enabled} disabled={busy} onChange={enabled => void run(() => window.goodbuddy.knowledge.externalInstancesSetEnabled({ instanceId: instance.id, enabled }))} />
<button type="button" className="secondary-button" disabled={busy || !instance.enabled || instance.credentialStatus !== 'configured'} onClick={() => void run(async () => { const value = await window.goodbuddy.knowledge.externalInstancesTest({ instanceId: instance.id }); notify({ tone: value.probeStatus === 'auth-failed' || value.probeStatus === 'failed' || value.probeStatus === 'unreachable' ? 'error' : 'info', message: `${instance.name}: ${t(`external.probe.${value.probeStatus}`)}` }) })}>{t('external.testConnection')}</button>
<button type="button" className="secondary-button" disabled={busy} onClick={() => edit(instance)}>{t('actions.edit')}</button><button type="button" className="danger-ghost" disabled={busy} onClick={() => setDeleting(instance)}>{t('actions.remove')}</button>
</div>
</li>)}</ul>
</>}
</div>
</section>
</div>, document.body)
}
function ExternalRetrievalTest({ library, instance }: { library: KnowledgeLibrary; instance?: ExternalKnowledgeInstanceSummary }): React.JSX.Element {
const { t } = useTranslation('knowledge')
const [query, setQuery] = useState('')
const [result, setResult] = useState<ExternalKnowledgeTestResult>()
const [error, setError] = useState<string>()
const [busy, setBusy] = useState(false)
const generation = useRef(0)
const errorId = useId()
useEffect(() => () => { generation.current++ }, [])
const binding = library.external!
const available = instance?.enabled && instance.credentialStatus === 'configured'
return <form className="external-knowledge__fields" onSubmit={event => {
event.preventDefault()
if (busy || !available || !query.trim()) return
const current = ++generation.current
setBusy(true); setError(undefined); setResult(undefined)
void window.goodbuddy.knowledge.externalRetrievalTest({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId, commonConfig: binding.commonConfig, providerConfig: binding.providerConfig, testQuery: query }).then(value => { if (current === generation.current) setResult(value) }).catch(reason => { if (current === generation.current) setError(errorMessage(reason)) }).finally(() => { if (current === generation.current) setBusy(false) })
}}>
<p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p>
<p>{t('external.queryNote')}</p>
{!available && <p role="status">{t(`external.states.${externalLibraryStatus(library, instance ? [instance] : [])}`)}</p>}
<Field label={t('external.query')}><textarea required disabled={busy} maxLength={4000} value={query} aria-describedby={error ? errorId : undefined} onChange={event => { generation.current++; setError(undefined); setResult(undefined); setQuery(event.currentTarget.value) }} /></Field>
<button type="submit" className="secondary-button" disabled={busy || !available || !query.trim()}>{t(busy ? 'external.testing' : 'external.test')}</button>
{error && <p role="alert" id={errorId}>{error}</p>}
{result && <TestResults result={result} />}
</form>
}
export function ExternalLibraryDetail({ library, instances, libraries, onChanged, notify, onManage, onUseInChat }: {
library: KnowledgeLibrary; instances: ExternalKnowledgeInstanceSummary[]; libraries: readonly KnowledgeLibrary[]; onChanged: Changed; notify: Notify; onManage: () => void; onUseInChat: (id: string) => void
}): React.JSX.Element {
const { t } = useTranslation('knowledge')
const [tab, setTab] = useState<'overview' | 'config' | 'test'>('overview')
const [configVisited, setConfigVisited] = useState(false)
const selectTab = (value: typeof tab): void => { if (value === 'config') setConfigVisited(true); setTab(value) }
const [removing, setRemoving] = useState(false)
const [busy, setBusy] = useState(false)
const [remoteDetail, setRemoteDetail] = useState<ExternalKnowledgeCatalogItem>()
const [detailError, setDetailError] = useState<string>()
const detailGeneration = useRef(0)
useEffect(() => () => { detailGeneration.current++ }, [])
const binding = library.external!
const instance = instances.find(item => item.id === binding.instanceId)
return <div className="external-knowledge">
<header><h2>{library.name}</h2><p>{externalProviderNames[binding.provider]} · {instance?.name} · {binding.remoteName}</p><ScopeBadge scope={{ kind: 'global' }} /></header>
<PageTabs idPrefix={`external-${library.id}`} ariaLabel={library.name} value={tab} onChange={selectTab} tabs={[{ id: 'overview', label: t('external.overview') }, { id: 'config', label: t('external.config') }, { id: 'test', label: t('external.test') }]} />
{tab !== 'config' && <div role="tabpanel" id={`external-${library.id}-panel-${tab}`} aria-labelledby={`external-${library.id}-tab-${tab}`}>
{tab === 'overview' ? <section className="external-knowledge__fields">
<dl><dt>{t('external.remoteId')}</dt><dd className="external-knowledge__identifier">{binding.remoteKnowledgeBaseId}</dd><dt>{t('external.status')}</dt><dd>{t(`external.states.${externalLibraryStatus(library, instances)}`)}</dd><dt>{t('external.lastTest')}</dt><dd>{binding.lastVerifiedAt ? <ExternalTime value={binding.lastVerifiedAt} /> : t('external.never')}</dd></dl>
<p>{t('external.remoteData')}</p><p>{t('external.localOnly')}</p>
<button type="button" className="secondary-button" disabled={busy} onClick={() => {
const current = ++detailGeneration.current
setBusy(true); setDetailError(undefined)
void window.goodbuddy.knowledge.externalCatalogGet({ instanceId: binding.instanceId, remoteKnowledgeBaseId: binding.remoteKnowledgeBaseId }).then(value => { if (current === detailGeneration.current) setRemoteDetail(value) }).catch(error => { if (current === detailGeneration.current) setDetailError(errorMessage(error)) }).finally(() => { if (current === detailGeneration.current) setBusy(false) })
}}>{t('external.refreshDetail')}</button>
{detailError && <p role="alert">{detailError}</p>}
{remoteDetail && <p role="status">{remoteDetail.name} · {remoteDetail.description}</p>}
<div className="external-knowledge__actions"><button type="button" className="primary-button" disabled={externalLibraryStatus(library, instances) !== 'ready'} onClick={() => onUseInChat(library.id)}>{t('actions.useInChat')}</button><button type="button" className="secondary-button" onClick={() => selectTab('config')}>{t('external.rebind')}</button><button type="button" className="secondary-button" onClick={onManage}>{t('external.manage')}</button><button type="button" className="danger-ghost" onClick={() => setRemoving(true)}>{t('external.remove')}</button></div>
{removing && <div className="external-knowledge__group"><p>{t('external.removeBindingConfirm')}</p><div className="external-knowledge__actions"><button type="button" className="secondary-button" autoFocus disabled={busy} onClick={() => setRemoving(false)}>{t('actions.cancel')}</button><button type="button" className="danger-button" disabled={busy} onClick={() => { setBusy(true); void window.goodbuddy.knowledge.deleteLibrary(library.id).then(async () => { await onChanged(); notify({ tone: 'success', message: t('external.removed') }) }).catch(error => notify({ tone: 'error', message: errorMessage(error) })).finally(() => setBusy(false)) }}>{t('external.remove')}</button></div></div>}
</section> : <ExternalRetrievalTest key={JSON.stringify([binding, instance])} library={library} instance={instance} />}
</div>}
{configVisited && <div hidden={tab !== 'config'} role="tabpanel" id={`external-${library.id}-panel-config`} aria-labelledby={`external-${library.id}-tab-config`}><ExternalBindingForm provider={binding.provider} instances={instances} libraries={libraries} library={library} onCancel={() => { setConfigVisited(false); setTab('overview') }} onChanged={onChanged} notify={notify} onManage={onManage} /></div>}
</div>
}
@@ -30,6 +30,14 @@ afterEach(() => {
}) })
describe('KnowledgeCitationDialog', () => { describe('KnowledgeCitationDialog', () => {
it('reads historical external snippets and typed scores without local source actions', () => {
const onOpenSource = vi.fn()
render(<KnowledgeCitationDialog reference={{ ...reference, documentId: undefined, chunkId: undefined, external: { kind: 'external', provider: 'fastgpt', instanceId: 'removed-instance', remoteKnowledgeBaseId: 'remote-library', providerScores: [{ type: 'embedding', value: 0.8, index: 2 }] } }} onClose={vi.fn()} onOpenSource={onOpenSource} />)
expect(screen.getByText(reference.snippet)).toBeInTheDocument()
expect(screen.getByText('embedding [2]: 0.8')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '打开来源' })).not.toBeInTheDocument()
expect(onOpenSource).not.toHaveBeenCalled()
})
it('shows matched and surrounding context and restores close behavior', async () => { it('shows matched and surrounding context and restores close behavior', async () => {
const onClose = vi.fn() const onClose = vi.fn()
const onOpenSource = vi.fn(async () => undefined) const onOpenSource = vi.fn(async () => undefined)
+16 -6
View File
@@ -33,6 +33,7 @@ export function KnowledgeCitationDialog({
onOpenSource onOpenSource
}: KnowledgeCitationDialogProps): React.JSX.Element { }: KnowledgeCitationDialogProps): React.JSX.Element {
const { t } = useTranslation('app') const { t } = useTranslation('app')
const { t: tk } = useTranslation('knowledge')
const dialogRef = useRef<HTMLDivElement>(null) const dialogRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null) const closeRef = useRef<HTMLButtonElement>(null)
const [opening, setOpening] = useState(false) const [opening, setOpening] = useState(false)
@@ -43,6 +44,7 @@ export function KnowledgeCitationDialog({
}, []) }, [])
const openSource = async (): Promise<void> => { const openSource = async (): Promise<void> => {
if (reference.external || !reference.documentId) return
setOpening(true) setOpening(true)
setOpenError(undefined) setOpenError(undefined)
try { try {
@@ -84,7 +86,7 @@ export function KnowledgeCitationDialog({
<h2 id="knowledge-citation-dialog-title"> <h2 id="knowledge-citation-dialog-title">
{t('chat.citations.contextTitle')} {t('chat.citations.contextTitle')}
</h2> </h2>
<p>{t('chat.citations.contextDescription')}</p> <p>{reference.external ? tk('external.snapshot') : t('chat.citations.contextDescription')}</p>
</div> </div>
<button <button
aria-label={t('chat.citations.closeContext')} aria-label={t('chat.citations.closeContext')}
@@ -99,13 +101,21 @@ export function KnowledgeCitationDialog({
</header> </header>
<dl className="knowledge-citation-dialog__metadata"> <dl className="knowledge-citation-dialog__metadata">
{reference.external && <>
<div><dt>{tk('external.provider')}</dt><dd>{({ dify: 'Dify', fastgpt: 'FastGPT', ragflow: 'RAGFlow' })[reference.external.provider]}</dd></div>
<div><dt>{tk('external.instance')}</dt><dd>{reference.external.instanceId}</dd></div>
<div><dt>{tk('external.remoteId')}</dt><dd>{reference.external.remoteKnowledgeBaseId}</dd></div>
{reference.external.remoteDocumentId && <div><dt>{tk('external.documentId')}</dt><dd>{reference.external.remoteDocumentId}</dd></div>}
{reference.external.remoteChunkId && <div><dt>{tk('external.chunkId')}</dt><dd>{reference.external.remoteChunkId}</dd></div>}
<div><dt>{tk('external.providerScore')}</dt>{reference.external.providerScore !== undefined && <dd>{reference.external.providerScore}</dd>}{reference.external.providerScores?.map((score, index) => <dd key={index}>{score.type}{score.index === undefined ? '' : ` [${score.index}]`}: {score.value}</dd>)}</div>
</>}
<div> <div>
<dt>{reference.documentName}</dt> <dt>{reference.documentName}</dt>
<dd>{context?.sourceName ?? reference.sourceName}</dd> <dd>{context?.sourceName ?? reference.sourceName}</dd>
</div> </div>
{(context?.locator ?? reference.locator) && ( {(context?.locator ?? reference.external?.location ?? reference.locator) && (
<div> <div>
<dt>{context?.locator ?? reference.locator}</dt> <dt>{context?.locator ?? reference.external?.location ?? reference.locator}</dt>
{reference.score !== undefined && ( {reference.score !== undefined && (
<dd> <dd>
{t('chat.citations.score', { {t('chat.citations.score', {
@@ -117,7 +127,7 @@ export function KnowledgeCitationDialog({
)} )}
</dl> </dl>
{loading ? ( {reference.external ? <div className="knowledge-citation-dialog__content"><section><h3>{t('chat.citations.matchedChunk')}</h3><p>{reference.snippet}</p></section></div> : loading ? (
<div <div
aria-live="polite" aria-live="polite"
className="knowledge-citation-dialog__state" className="knowledge-citation-dialog__state"
@@ -175,7 +185,7 @@ export function KnowledgeCitationDialog({
> >
{t('chat.citations.closeContext')} {t('chat.citations.closeContext')}
</button> </button>
<button {!reference.external && reference.documentId && <button
className="primary-button" className="primary-button"
disabled={opening} disabled={opening}
onClick={() => void openSource()} onClick={() => void openSource()}
@@ -187,7 +197,7 @@ export function KnowledgeCitationDialog({
<ExternalLink aria-hidden="true" size={15} /> <ExternalLink aria-hidden="true" size={15} />
)} )}
{t('chat.citations.openSource')} {t('chat.citations.openSource')}
</button> </button>}
</footer> </footer>
</section> </section>
</div>, </div>,
@@ -40,8 +40,9 @@ export type KnowledgeRetrievalChannelDetail = {
} }
export type KnowledgeRetrievalWorkbenchResult = { export type KnowledgeRetrievalWorkbenchResult = {
chunkId: string external?: import('../../shared/external-knowledge-contracts').ExternalKnowledgeLocator
documentId: string chunkId?: string
documentId?: string
rank: number rank: number
documentName: string documentName: string
sourceName: string sourceName: string
@@ -1013,7 +1014,7 @@ export function KnowledgeRetrievalWorkbench({
{results.map((result) => ( {results.map((result) => (
<li <li
className="knowledge-retrieval-result" className="knowledge-retrieval-result"
key={result.chunkId} key={result.chunkId ?? `external-${result.rank}`}
> >
<article <article
aria-label={t('retrieval.results.resultAriaLabel', { aria-label={t('retrieval.results.resultAriaLabel', {
@@ -1094,7 +1095,7 @@ export function KnowledgeRetrievalWorkbench({
<p>{result.contextText}</p> <p>{result.contextText}</p>
</details> </details>
)} )}
<footer> {!result.external && result.documentId && result.chunkId && <footer>
<button <button
className="secondary-button" className="secondary-button"
onClick={() => onViewContext(result)} onClick={() => onViewContext(result)}
@@ -1111,7 +1112,7 @@ export function KnowledgeRetrievalWorkbench({
<ExternalLink aria-hidden="true" size={14} /> <ExternalLink aria-hidden="true" size={14} />
{t('retrieval.actions.openSource')} {t('retrieval.actions.openSource')}
</button> </button>
</footer> </footer>}
</article> </article>
</li> </li>
))} ))}
+21 -1
View File
@@ -262,6 +262,25 @@ function createProps(
} }
describe('KnowledgeWorkspace', () => { describe('KnowledgeWorkspace', () => {
it('searches external names and instances and filters the unified library list', async () => {
await i18n.changeLanguage('en-US')
const external = { ...library, id: 'external-1', name: 'Company handbook', external: { knowledgeBaseId: 'external-1', instanceId: 'instance-1', provider: 'dify' as const, remoteKnowledgeBaseId: 'remote-1', remoteName: 'Remote policies', commonConfig: { resultLimit: 6, requestTimeoutMs: 15000, maxSnippetCharacters: 4000 }, providerConfig: { provider: 'dify' as const, useDatasetDefaults: true as const }, lastVerifiedAt: '2026-09-12' } }
const props = createProps({ libraries: [library, external], externalInstances: [{ id: 'instance-1', name: 'Enterprise service', provider: 'dify', baseUrl: 'https://knowledge.example', enabled: true, credentialStatus: 'configured', probeStatus: 'healthy', bindingCount: 1 }] })
render(<KnowledgeWorkspace {...props} />)
const nav = screen.getByRole('navigation', { name: 'Library list' })
for (const query of ['Remote policies', 'Enterprise service', 'dify']) {
fireEvent.change(screen.getByLabelText('Search libraries'), { target: { value: query } })
expect(within(nav).getAllByRole('button')).toHaveLength(1)
expect(within(nav).getByRole('button', { name: /Company handbook/ })).toBeInTheDocument()
}
fireEvent.change(screen.getByLabelText('Library source filter'), { target: { value: 'local' } })
expect(within(nav).getByText('No matching results')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
expect(within(nav).getAllByRole('button')).toHaveLength(2)
fireEvent.click(within(nav).getByRole('button', { name: /Company handbook/ }))
expect(props.onSelectLibrary).toHaveBeenCalledWith(external.id)
})
afterEach(() => { afterEach(() => {
cleanup() cleanup()
vi.clearAllMocks() vi.clearAllMocks()
@@ -2022,7 +2041,8 @@ describe('KnowledgeWorkspace', () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
expect( expect(
screen.queryByRole('button', { name: '新建知识库' }) screen.queryByRole('button', { name: '新建知识库' })
).not.toBeInTheDocument() ).toBeDisabled()
expect(screen.getByRole('button', { name: '管理外部实例' })).toBeInTheDocument()
}) })
it('shows a retryable load error instead of the first-library empty state', () => { it('shows a retryable load error instead of the first-library empty state', () => {
+44 -18
View File
@@ -94,6 +94,10 @@ import {
} from './KnowledgeRetrievalWorkbench' } from './KnowledgeRetrievalWorkbench'
import { activateModalFocus, trapTabFocus } from './dialog-focus' import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection' import { KnowledgeEmbeddingIndexSection } from './KnowledgeEmbeddingIndexSection'
import { ExternalBindingForm, ExternalInstanceManager, ExternalLibraryDetail, externalProviderNames, externalLibraryStatus } from './ExternalKnowledge'
import type { ExternalKnowledgeInstanceSummary, ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts'
import type { KnowledgeSnapshot } from '../../shared/contracts'
import type { AppNotificationInput } from './notifications'
type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart') type KnowledgeGraphChartModule = typeof import('./KnowledgeGraphChart')
@@ -226,6 +230,9 @@ export type KnowledgeRelationInput = {
} }
export type KnowledgeWorkspaceProps = { export type KnowledgeWorkspaceProps = {
externalInstances?: ExternalKnowledgeInstanceSummary[]
onExternalChanged?: (snapshot?: KnowledgeSnapshot, createdId?: string) => void | Promise<void>
notify?: (input: AppNotificationInput) => void
libraries: readonly KnowledgeLibrary[] libraries: readonly KnowledgeLibrary[]
selectedLibraryId?: string selectedLibraryId?: string
sources: readonly KnowledgeSource[] sources: readonly KnowledgeSource[]
@@ -455,8 +462,9 @@ function toWorkbenchResponse(
rerank: response.diagnostics.rerank rerank: response.diagnostics.rerank
}, },
results: response.results.map((result) => { results: response.results.map((result) => {
const context = contextByChunkId.get(result.chunkId) const context = result.chunkId ? contextByChunkId.get(result.chunkId) : undefined
return { return {
external: result.external,
chunkId: result.chunkId, chunkId: result.chunkId,
documentId: result.documentId, documentId: result.documentId,
rank: result.rank, rank: result.rank,
@@ -4302,6 +4310,9 @@ function GraphView({
} }
export function KnowledgeWorkspace({ export function KnowledgeWorkspace({
externalInstances = [],
onExternalChanged = () => {},
notify = () => {},
libraries, libraries,
selectedLibraryId, selectedLibraryId,
sources, sources,
@@ -4354,6 +4365,15 @@ export function KnowledgeWorkspace({
const { i18n, t } = useTranslation('knowledge') const { i18n, t } = useTranslation('knowledge')
const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language) const locale = resolvedLocale(i18n.resolvedLanguage ?? i18n.language)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [creationType, setCreationType] = useState<'local' | ExternalKnowledgeProvider>('local')
const [managingExternal, setManagingExternal] = useState(false)
const [librarySearch, setLibrarySearch] = useState('')
const [librarySource, setLibrarySource] = useState('all')
const filteredLibraries = libraries.filter(library => {
if (librarySource !== 'all' && (library.external?.provider ?? 'local') !== librarySource) return false
const instanceName = externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? ''
return [library.name, library.external?.remoteName, library.external?.provider ?? t('external.local'), instanceName].join(' ').toLocaleLowerCase(locale).includes(librarySearch.trim().toLocaleLowerCase(locale))
})
const [mobileListOpen, setMobileListOpen] = useState(false) const [mobileListOpen, setMobileListOpen] = useState(false)
const [tab, setTab] = useState<WorkspaceTab>('documents') const [tab, setTab] = useState<WorkspaceTab>('documents')
const [graphTab, setGraphTab] = const [graphTab, setGraphTab] =
@@ -4505,9 +4525,11 @@ export function KnowledgeWorkspace({
return ( return (
<div className="knowledge-page"> <div className="knowledge-page">
{managingExternal && <ExternalInstanceManager instances={externalInstances} libraries={libraries} onClose={() => setManagingExternal(false)} onChanged={onExternalChanged} notify={notify} />}
<PageHeader <PageHeader
actions={ actions={
libraries.length > 0 ? ( <>
<button type="button" className="secondary-button" onClick={() => setManagingExternal(true)}><Database size={16} />{t('external.manage')}</button>
<button <button
className="primary-button" className="primary-button"
disabled={loading} disabled={loading}
@@ -4520,7 +4542,7 @@ export function KnowledgeWorkspace({
<Plus aria-hidden="true" size={16} /> <Plus aria-hidden="true" size={16} />
{t('actions.newLibrary')} {t('actions.newLibrary')}
</button> </button>
) : undefined </>
} }
description={t('page.description')} description={t('page.description')}
headingId="knowledge-workspace-title" headingId="knowledge-workspace-title"
@@ -4542,22 +4564,27 @@ export function KnowledgeWorkspace({
<strong>{t('workspace.libraryList')}</strong> <strong>{t('workspace.libraryList')}</strong>
</span> </span>
<small> <small>
{formatNumber(libraries.length, locale)} {formatNumber(filteredLibraries.length, locale)}
</small> </small>
</div> </div>
<div className="external-knowledge__filters">
<label className="knowledge-field">{t('external.searchLibraries')}<input type="search" value={librarySearch} onChange={event => setLibrarySearch(event.currentTarget.value)} /></label>
<label className="knowledge-field">{t('external.sourceFilter')}<select value={librarySource} onChange={event => setLibrarySource(event.currentTarget.value)}><option value="all">{t('external.allSources')}</option><option value="local">{t('external.local')}</option>{Object.entries(externalProviderNames).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
</div>
<nav <nav
aria-label={t('workspace.libraryList')} aria-label={t('workspace.libraryList')}
className="knowledge-workspace__library-nav" className="knowledge-workspace__library-nav"
> >
{libraries.length === 0 ? ( {(loading || loadError) && libraries.length === 0 ? null : filteredLibraries.length === 0 ? (
<div className="knowledge-workspace__library-empty"> <div className="knowledge-workspace__library-empty">
{t('workspace.libraryListEmpty')} {t(libraries.length ? 'external.noResults' : 'workspace.libraryListEmpty')}
{(librarySearch || librarySource !== 'all') && <button type="button" className="secondary-button" onClick={() => { setLibrarySearch(''); setLibrarySource('all') }}>{t('external.clearFilters')}</button>}
</div> </div>
) : ( ) : (
<ul className="knowledge-workspace__library-list"> <ul className="knowledge-workspace__library-list">
{libraries.map((library) => { {filteredLibraries.map((library) => {
const selected = library.id === selectedLibrary?.id const selected = library.id === selectedLibrary?.id
const libraryMeta = t('workspace.libraryMeta', { const libraryMeta = library.external ? `${externalProviderNames[library.external.provider]} · ${externalInstances.find(instance => instance.id === library.external?.instanceId)?.name ?? library.external.instanceId} · ${t(`external.states.${externalLibraryStatus(library, externalInstances)}`)}` : t('workspace.libraryMeta', {
ready: formatNumber( ready: formatNumber(
library.indexedDocumentCount, library.indexedDocumentCount,
locale locale
@@ -4658,27 +4685,24 @@ export function KnowledgeWorkspace({
title={t('errors.loadTitle')} title={t('errors.loadTitle')}
/> />
) : creating ? ( ) : creating ? (
<>
<SegmentedControl ariaLabel={t('external.type')} value={creationType} onChange={setCreationType} options={[{ value: 'local', label: t('external.local') }, ...Object.entries(externalProviderNames).map(([value, label]) => ({ value: value as ExternalKnowledgeProvider, label }))]} />
{creationType === 'local' ?
<CreateLibraryWizard <CreateLibraryWizard
onCancel={() => setCreating(false)} onCancel={() => setCreating(false)}
onCreate={onCreateLibrary} onCreate={onCreateLibrary}
/> />
: <ExternalBindingForm key={creationType} provider={creationType} instances={externalInstances} libraries={libraries} onCancel={() => setCreating(false)} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} />}
</>
) : !selectedLibrary ? ( ) : !selectedLibrary ? (
<EmptyState <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')} description={t('empty.description')}
icon={<BookOpen size={34} />} icon={<BookOpen size={34} />}
level="page" level="page"
title={t('empty.title')} title={t('empty.title')}
/> />
) : selectedLibrary.external ? (
<ExternalLibraryDetail key={selectedLibrary.id} library={selectedLibrary} instances={externalInstances} libraries={libraries} onChanged={onExternalChanged} notify={notify} onManage={() => setManagingExternal(true)} onUseInChat={onUseInChat} />
) : ( ) : (
<> <>
<header <header
@@ -4948,6 +4972,7 @@ export function KnowledgeWorkspace({
libraryName={selectedLibrary.name} libraryName={selectedLibrary.name}
onClose={() => setRetrievalOpen(false)} onClose={() => setRetrievalOpen(false)}
onOpenSource={(result) => { onOpenSource={(result) => {
if (result.external || !result.documentId || !result.chunkId) return
void Promise.resolve( void Promise.resolve(
onOpenReferenceSource({ onOpenReferenceSource({
knowledgeBaseId: selectedLibrary.id, knowledgeBaseId: selectedLibrary.id,
@@ -5007,6 +5032,7 @@ export function KnowledgeWorkspace({
} }
}} }}
onViewContext={(result) => { onViewContext={(result) => {
if (result.external || !result.documentId || !result.chunkId) return
const document = libraryDocuments.find( const document = libraryDocuments.find(
(item) => item.id === result.documentId (item) => item.id === result.documentId
) )
+129
View File
@@ -0,0 +1,129 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { createInstance } from 'i18next'
import { I18nextProvider } from 'react-i18next'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectActivity, ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivity } from './conversation-activity'
const i18n = createInstance()
void i18n.init({
lng: 'en',
initImmediate: false,
resources: { en: { workspace: { projectActivity: {
title: 'All project activity', close: 'Close activity',
attentionCount: '{{count}} need attention', runningCount: '{{count}} running',
groups: { attention: 'Needs attention', running: 'Running' },
status: { running: 'Running', approval: 'Waiting for approval', question: 'Waiting for your answer', attention: 'Needs attention' }
} } } }
})
const activities: ConversationActivity[] = [
{ conversationId: 'running', projectId: 'local', projectName: 'Local', title: 'Build', status: 'running' },
{ conversationId: 'question', projectId: 'remote', projectName: 'Remote', title: 'Choose target', status: 'question' },
{ conversationId: 'approval', projectId: 'remote', projectName: 'Remote', title: 'Approve command', status: 'approval' },
{ conversationId: 'unloaded', projectName: 'Channel', title: 'Background task', status: 'attention' }
]
function view(rows = activities, onOpenConversation = vi.fn()): React.JSX.Element {
return (
<I18nextProvider i18n={i18n}>
<div className="app-shell">
<button type="button">Destination</button>
<ProjectActivity activities={rows} onOpenConversation={onOpenConversation} />
</div>
</I18nextProvider>
)
}
afterEach(cleanup)
describe('ProjectActivity', () => {
it('hides idle activity and zero counts', () => {
render(view([]))
expect(screen.queryByRole('button', { name: /All project activity/ })).not.toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const { container } = render(
<I18nextProvider i18n={i18n}><ProjectActivityCounts attention={0} running={0} /></I18nextProvider>
)
expect(container).toBeEmptyDOMElement()
})
it('shows global counts and attention-first groups with precise labels', () => {
render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity 3 need attention 1 running/ }))
const dialog = screen.getByRole('dialog', { name: 'All project activity' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(within(dialog).getAllByRole('heading', { level: 3 }).map((heading) => heading.textContent))
.toEqual(['Needs attention', 'Running'])
expect(within(dialog).getByRole('button', { name: 'Remote Choose target Waiting for your answer' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Remote Approve command Waiting for approval' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Channel Background task Needs attention' })).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Local Build Running' })).toBeInTheDocument()
})
it('isolates the background, traps Tab both ways, and restores focus on Escape', () => {
const { container } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog')
const close = within(dialog).getByRole('button', { name: 'Close activity' })
const last = within(dialog).getByRole('button', { name: 'Local Build Running' })
expect(close).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(true)
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
fireEvent.keyDown(last, { key: 'Tab' })
expect(close).toHaveFocus()
fireEvent.keyDown(close, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(container.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
})
it('navigates directly to an unloaded conversation without stealing destination focus', () => {
const onOpen = vi.fn(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
screen.getByRole('button', { name: 'Destination' }).focus()
})
render(view(activities, onOpen))
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
fireEvent.click(screen.getByRole('button', { name: 'Channel Background task Needs attention' }))
expect(onOpen).toHaveBeenCalledExactlyOnceWith('unloaded')
expect(screen.getByRole('button', { name: 'Destination' })).toHaveFocus()
})
it('updates live rows without refocusing and stays closed after becoming idle', () => {
const { rerender } = render(view())
fireEvent.click(screen.getByRole('button', { name: /All project activity/ }))
const row = screen.getByRole('button', { name: 'Remote Choose target Waiting for your answer' })
row.focus()
rerender(view(activities.filter((activity) => activity.status !== 'running')))
expect(row).toHaveFocus()
expect(screen.queryByRole('region', { name: 'Running' })).not.toBeInTheDocument()
rerender(view([]))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(document.querySelector<HTMLElement>('.app-shell')?.inert).toBe(false)
rerender(view())
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('restores trigger focus when dismissed with the close button', () => {
render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Close activity' }))
expect(trigger).toHaveFocus()
})
it('still handles Escape when a background update removes the focused row', () => {
const { rerender } = render(view())
const trigger = screen.getByRole('button', { name: /All project activity/ })
fireEvent.click(trigger)
screen.getByRole('button', { name: 'Local Build Running' }).focus()
rerender(view(activities.filter((activity) => activity.status !== 'running')))
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
})
+175
View File
@@ -0,0 +1,175 @@
import { ChevronRight, CircleAlert, LoaderCircle, X } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import './project-activity.css'
export type ProjectActivityProps = {
activities: ConversationActivity[]
onOpenConversation: (id: string) => void
}
export type ProjectActivityCountsProps = { running: number; attention: number }
export function ProjectActivityCounts({
running,
attention
}: ProjectActivityCountsProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
if (!running && !attention) return null
return (
<span className="project-activity__counts">
{attention > 0 && (
<span className="project-activity__attention">
<CircleAlert aria-hidden="true" size={13} />
{t('projectActivity.attentionCount', { count: attention })}
</span>
)}
{' '}
{running > 0 && (
<span className="project-activity__running">
<LoaderCircle aria-hidden="true" size={13} />
{t('projectActivity.runningCount', { count: running })}
</span>
)}
</span>
)
}
function ActivityDialog({
activities,
onOpenConversation,
onClose
}: ProjectActivityProps & { onClose: () => void }): React.JSX.Element {
const { t } = useTranslation('workspace')
const dialogRef = useRef<HTMLElement>(null)
const titleId = useId()
const handleKeyDown = useEffectEvent((event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
}
})
useEffect(() => {
const releaseFocus = activateModalFocus(
() => dialogRef.current?.querySelector<HTMLButtonElement>('button') ?? null
)
const onKeyDown = (event: KeyboardEvent): void => handleKeyDown(event)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
releaseFocus()
}
}, [])
return createPortal(
<div
className="project-activity__backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<section
aria-labelledby={titleId}
aria-modal="true"
className="project-activity__dialog"
ref={dialogRef}
role="dialog"
>
<header className="project-activity__header">
<h2 id={titleId}>{t('projectActivity.title')}</h2>
<button
aria-label={t('projectActivity.close')}
className="icon-button"
onClick={onClose}
title={t('projectActivity.close')}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</header>
<div className="project-activity__content">
{(['attention', 'running'] as const).map((group) => {
const rows = activities.filter((activity) =>
group === 'running' ? activity.status === 'running' : activity.status !== 'running'
)
if (!rows.length) return null
return (
<section aria-labelledby={`${titleId}-${group}`} key={group}>
<h3 id={`${titleId}-${group}`}>{t(`projectActivity.groups.${group}`)}</h3>
<ul>
{rows.map((activity) => (
<li key={activity.conversationId}>
<button
className="project-activity__row"
onClick={() => {
// Release modal isolation and restore focus before navigation owns it.
flushSync(onClose)
onOpenConversation(activity.conversationId)
}}
type="button"
>
<span className="project-activity__identity">
<small>{activity.projectName}</small>{' '}
<span>{activity.title}</span>
</span>{' '}
<span className={`project-activity__${group}`}>
{t(`projectActivity.status.${activity.status}`)}
</span>
<ChevronRight aria-hidden="true" size={14} />
</button>
</li>
))}
</ul>
</section>
)
})}
</div>
</section>
</div>,
document.body
)
}
export function ProjectActivity({
activities,
onOpenConversation
}: ProjectActivityProps): React.JSX.Element | null {
const { t } = useTranslation('workspace')
const [open, setOpen] = useState(false)
if (!activities.length) {
if (open) setOpen(false)
return null
}
const running = activities.filter((activity) => activity.status === 'running').length
return (
<>
<button
aria-expanded={open}
aria-haspopup="dialog"
className="project-activity__summary"
onClick={(event) => {
event.currentTarget.focus()
setOpen(true)
}}
type="button"
>
<span>{t('projectActivity.title')}</span>{' '}
<ProjectActivityCounts attention={activities.length - running} running={running} />
<ChevronRight aria-hidden="true" size={14} />
</button>
{open && (
<ActivityDialog
activities={activities}
onClose={() => setOpen(false)}
onOpenConversation={onOpenConversation}
/>
)}
</>
)
}
+151
View File
@@ -75,6 +75,7 @@ const project: AssistantProject = {
afterEach(async () => { afterEach(async () => {
cleanup() cleanup()
vi.useRealTimers()
await i18n.changeLanguage('zh-CN') await i18n.changeLanguage('zh-CN')
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
@@ -308,6 +309,50 @@ function installRemoteApi(
} }
} }
describe('ProjectSwitcher project activity integration', () => {
it('keeps per-project title counts and menu ordering stable across recovery and Host states', async () => {
const api = installRemoteApi({ connectionState: 'connecting' })
const remote: AssistantProject = {
...project, id: remoteProjectId, name: 'Remote activity',
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
}
const idle = { ...remote, id: 'idle-project', name: 'Idle remote' }
const props = {
activeProjectId: project.id, projects: [project, remote, idle],
remoteProjectsEnabled: true, onArchive: vi.fn(), onCreate: vi.fn(),
onDelete: vi.fn(), onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
activityByProjectId: {
[project.id]: { running: 1, attention: 0 },
[remote.id]: { running: 2, attention: 3 }
}
}
const { rerender } = render(<ProjectSwitcher {...props} />)
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
const menu = screen.getByRole('menu', { name: '当前项目' })
await within(menu).findByRole('group', { name: 'SSH 主机:Build host' })
for (const stage of ['agent', 'failed', 'completed'] as const) {
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
[remote.id]: { projectId: remote.id, requestId: 'activity-recovery', stage,
message: 'Host unreachable', retryable: true }
}} />)
act(() => api.emitConnectionStatus(stage === 'agent' ? 'connecting' : stage === 'failed' ? 'disconnected' : 'ready'))
const rows = within(menu).getAllByRole('menuitemradio')
expect(rows.map((row) => row.querySelector('b')?.textContent))
.toEqual(['Local project', 'Remote activity', 'Idle remote'])
expect(rows[0]!.querySelector('.project-switcher__project-heading'))
.toHaveTextContent('Local project 1 个运行中')
const heading = rows[1]!.querySelector('.project-switcher__project-heading')!
expect(heading).toHaveTextContent('Remote activity3 个待处理 2 个运行中')
expect(heading.querySelector('b')?.nextElementSibling).toHaveClass('project-activity__counts')
expect(rows[2]!.querySelector('.project-activity__counts')).toBeNull()
}
fireEvent.click(within(menu).getByRole('menuitemradio', { name: /Remote activity/u }))
expect(props.onSelect).toHaveBeenCalledExactlyOnceWith(remote.id)
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
})
describe('ProjectSwitcher runtime fields', () => { describe('ProjectSwitcher runtime fields', () => {
it('creates an ordinary project with DeepSeek Harness', async () => { it('creates an ordinary project with DeepSeek Harness', async () => {
const { onCreate } = renderSwitcher() const { onCreate } = renderSwitcher()
@@ -611,6 +656,112 @@ describe('ProjectSwitcher managed SSH projects', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('expires completion feedback once per request across project switches and menu mounts', async () => {
await i18n.changeLanguage('en-US')
installRemoteApi()
vi.useFakeTimers()
const remote: AssistantProject = {
...project,
id: remoteProjectId,
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
}
const props = {
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
onRemoteCommitted: vi.fn(), onSelect: vi.fn(),
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
projects: [project, remote], remoteProjectsEnabled: true
}
const completed: RemoteProjectRecoveryState = {
projectId: remote.id,
requestId: '00000000-0000-4000-8000-000000000301',
stage: 'completed'
}
const view = (activeProjectId: string, state: RemoteProjectRecoveryState = completed) => (
<ProjectSwitcher {...props} activeProjectId={activeProjectId}
recoveryByProjectId={{ [remote.id]: { ...state } }} />
)
const { rerender, unmount } = render(view(remote.id))
const trigger = screen.getByRole('button', { name: 'Current project' })
expect(within(trigger).getByRole('status')).toHaveTextContent('Recovery completed')
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
rerender(view(project.id))
fireEvent.click(trigger)
expect(within(screen.getByRole('menu')).getByRole('status'))
.toHaveTextContent('Recovery completed')
await act(async () => { await vi.advanceTimersByTimeAsync(999) })
expect(screen.getByText('Recovery completed')).toBeInTheDocument()
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
fireEvent.click(trigger)
rerender(view(remote.id))
fireEvent.click(trigger)
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
expect(completed.stage).toBe('completed')
const next = { ...completed, requestId: '00000000-0000-4000-8000-000000000302' }
rerender(view(remote.id, { ...next, stage: 'network' }))
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
expect(within(trigger).getByRole('status')).toHaveAttribute('aria-busy', 'true')
rerender(view(remote.id, next))
expect(screen.getAllByText('Recovery completed')).toHaveLength(2)
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
rerender(view(remote.id, { ...next, requestId: 'new-request' }))
unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('expires background completions independently and cancels obsolete completion timers', async () => {
await i18n.changeLanguage('en-US')
installRemoteApi()
vi.useFakeTimers()
const remote: AssistantProject = {
...project, id: remoteProjectId,
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/srv/project' }
}
const second = { ...remote, id: 'second-project', name: 'Second remote' }
const onRetryRecovery = vi.fn(async () => undefined)
const props = {
activeProjectId: project.id,
onArchive: vi.fn(), onCreate: vi.fn(), onDelete: vi.fn(),
onRemoteCommitted: vi.fn(), onSelect: vi.fn(), onRetryRecovery,
onSelectRoot: vi.fn(), onUpdate: vi.fn(),
projects: [project, remote, second], remoteProjectsEnabled: true
}
const firstState: RemoteProjectRecoveryState = {
projectId: remote.id, requestId: 'first-request', stage: 'completed'
}
const secondState: RemoteProjectRecoveryState = {
projectId: second.id, requestId: 'second-request', stage: 'completed'
}
const { rerender } = render(<ProjectSwitcher {...props}
recoveryByProjectId={{ [remote.id]: firstState }} />)
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
[remote.id]: { ...firstState }, [second.id]: secondState
}} />)
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
fireEvent.click(screen.getByRole('button', { name: 'Current project' }))
const menu = screen.getByRole('menu')
expect(within(menu).getAllByText('Recovery completed')).toHaveLength(1)
expect(within(menu).getByRole('menuitemradio', { name: /Second remote/u }))
.toHaveTextContent('Recovery completed')
rerender(<ProjectSwitcher {...props} recoveryByProjectId={{
[remote.id]: firstState,
[second.id]: { ...secondState, requestId: 'retry-request', stage: 'failed',
message: 'Host unreachable', retryable: true }
}} />)
await act(async () => { await vi.advanceTimersByTimeAsync(5_000) })
expect(within(menu).getByRole('alert')).toHaveTextContent('Host unreachable')
await act(async () => {
fireEvent.click(within(menu).getByRole('menuitem', {
name: 'Retry recovery for project Second remote'
}))
})
expect(onRetryRecovery).toHaveBeenCalledWith(second.id)
expect(screen.queryByText('Recovery completed')).not.toBeInTheDocument()
})
it('renders an accessible failure and retries only the affected project', async () => { it('renders an accessible failure and retries only the affected project', async () => {
await i18n.changeLanguage('en-US') await i18n.changeLanguage('en-US')
const remoteProject: AssistantProject = { const remoteProject: AssistantProject = {
+64 -4
View File
@@ -50,6 +50,8 @@ import {
} from './ChannelProjectSettingsFields' } from './ChannelProjectSettingsFields'
import { getProjectDisplayText } from './project-display' import { getProjectDisplayText } from './project-display'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector' import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectActivityCounts } from './ProjectActivity'
import type { ConversationActivitySummary } from './conversation-activity'
import { ProjectWorkModeFields } from './ProjectWorkModeFields' import { ProjectWorkModeFields } from './ProjectWorkModeFields'
import { SegmentedControl } from './WorkspacePrimitives' import { SegmentedControl } from './WorkspacePrimitives'
import { displayErrorMessage } from './error-message' import { displayErrorMessage } from './error-message'
@@ -57,6 +59,7 @@ import { displayErrorMessage } from './error-message'
type ProjectSwitcherProps = { type ProjectSwitcherProps = {
projects: AssistantProject[] projects: AssistantProject[]
activeProjectId: string activeProjectId: string
activityByProjectId?: ConversationActivitySummary['byProjectId']
remoteProjectsEnabled?: boolean remoteProjectsEnabled?: boolean
runtimeSettings?: RuntimeSettings runtimeSettings?: RuntimeSettings
onArchive: (projectId: string) => Promise<void> onArchive: (projectId: string) => Promise<void>
@@ -207,6 +210,7 @@ function isRemoteAbsolutePath(value: string): boolean {
export function ProjectSwitcher({ export function ProjectSwitcher({
projects, projects,
activeProjectId, activeProjectId,
activityByProjectId = {},
remoteProjectsEnabled = false, remoteProjectsEnabled = false,
runtimeSettings, runtimeSettings,
onArchive, onArchive,
@@ -281,6 +285,55 @@ export function ProjectSwitcher({
const [projectMenuOpen, setProjectMenuOpen] = useState(false) const [projectMenuOpen, setProjectMenuOpen] = useState(false)
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] = const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
useState<string>() useState<string>()
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
useState<Record<string, string>>({})
const recoveryCompletionTimers = useRef(
new Map<string, { requestId: string; timer: ReturnType<typeof setTimeout> }>()
)
useEffect(() => {
const timers = recoveryCompletionTimers.current
for (const [projectId, entry] of timers) {
const state = recoveryByProjectId[projectId]
if (state?.stage !== 'completed' || state.requestId !== entry.requestId) {
clearTimeout(entry.timer)
timers.delete(projectId)
}
}
for (const [projectId, state] of Object.entries(recoveryByProjectId)) {
if (state.stage !== 'completed' || timers.has(projectId)) {
continue
}
// Keep the request entry after expiry so menu mounts and snapshots cannot replay it.
timers.set(projectId, {
requestId: state.requestId,
timer: setTimeout(() => {
setDismissedRecoveryByProjectId((current) => ({
...current,
[projectId]: state.requestId
}))
}, 3_000)
})
}
}, [recoveryByProjectId])
useEffect(() => {
const timers = recoveryCompletionTimers.current
return () => {
for (const entry of timers.values()) {
clearTimeout(entry.timer)
}
timers.clear()
}
}, [])
const visibleRecoveryByProjectId = Object.fromEntries(
Object.entries(recoveryByProjectId).filter(
([projectId, state]) =>
state.stage !== 'completed' ||
dismissedRecoveryByProjectId[projectId] !== state.requestId
)
)
const applySshHostsSnapshot = useCallback( const applySshHostsSnapshot = useCallback(
( (
snapshot: SshHostsSnapshot, snapshot: SshHostsSnapshot,
@@ -357,7 +410,7 @@ export function ProjectSwitcher({
: undefined : undefined
const activeProjectRecovery = const activeProjectRecovery =
activeProject?.executionSpace.kind === 'ssh' activeProject?.executionSpace.kind === 'ssh'
? recoveryByProjectId[activeProject.id] ? visibleRecoveryByProjectId[activeProject.id]
: undefined : undefined
const settingsProject = projects.find( const settingsProject = projects.find(
(project) => project.id === settingsProjectId (project) => project.id === settingsProjectId
@@ -1019,7 +1072,7 @@ export function ProjectSwitcher({
const projectDisplay = getProjectDisplayText(project, t) const projectDisplay = getProjectDisplayText(project, t)
const recovery = const recovery =
project.executionSpace.kind === 'ssh' project.executionSpace.kind === 'ssh'
? recoveryByProjectId[project.id] ? visibleRecoveryByProjectId[project.id]
: undefined : undefined
const detail = const detail =
project.kind === 'channel' project.kind === 'channel'
@@ -1062,7 +1115,12 @@ export function ProjectSwitcher({
> >
<ProjectIcon aria-hidden="true" size={16} /> <ProjectIcon aria-hidden="true" size={16} />
<span> <span>
<b>{projectDisplay.name}</b> <span className="project-switcher__project-heading">
<b>{projectDisplay.name}</b>
{activityByProjectId[project.id] && (
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
)}
</span>
<small>{detail}</small> <small>{detail}</small>
{recovery && <ProjectRecoveryStatus state={recovery} />} {recovery && <ProjectRecoveryStatus state={recovery} />}
</span> </span>
@@ -1388,9 +1446,10 @@ export function ProjectSwitcher({
onClick={() => closeDialog()} onClick={() => closeDialog()}
type="button" type="button"
> >
<X size={14} /> <X size={18} />
</button> </button>
</header> </header>
<div className="project-create-card__body">
{dialogMode === 'settings' && {dialogMode === 'settings' &&
settingsProject?.kind === 'channel' && settingsProject?.kind === 'channel' &&
runtimeSettings ? ( runtimeSettings ? (
@@ -2019,6 +2078,7 @@ export function ProjectSwitcher({
)} )}
</section> </section>
)} )}
</div>
<div className="project-create-card__actions"> <div className="project-create-card__actions">
{dialogMode === 'settings' && {dialogMode === 'settings' &&
settingsProject?.kind !== 'channel' && settingsProject?.kind !== 'channel' &&
@@ -121,9 +121,9 @@ describe('WorkspacePrimitives', () => {
expect(capabilitiesTabs).toMatch(/flex:\s*0 0 auto;/u) expect(capabilitiesTabs).toMatch(/flex:\s*0 0 auto;/u)
}) })
it('keeps whole-surface dialog scrollbars outside close-button hitboxes', () => { it('keeps dialog scrollbars outside close-button hitboxes', () => {
expect(stylesheet).toMatch( expect(stylesheet).toMatch(
/:is\(\s*\.project-create-card,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u /:is\(\s*\.project-create-card__body,\s*\.knowledge-citation-dialog__surface,\s*\.channel-qr-dialog,\s*\.document-parsing-diagnostic,\s*\.mcp-editor\s*\)\s*\{[^}]*scrollbar-gutter:\s*stable;/u
) )
}) })
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { deriveConversationActivity } from './conversation-activity'
const projects = [{ id: 'local', name: 'Local' }, { id: 'remote', name: 'Remote' }]
describe('deriveConversationActivity', () => {
it('counts each conversation once with question and approval ahead of task fallback', () => {
const conversations = [
{ id: 'question', title: 'Question', projectId: 'remote', messages: [
{ state: 'streaming', approval: {} }, { pendingQuestions: [{}, {}] }
] },
{ id: 'approval', title: 'Approval', projectId: 'local', messages: [{ approval: {} }] },
{ id: 'fallback', title: 'Fallback', projectId: 'remote', messages: [{ state: 'streaming' }] }
]
const tasks = conversations.flatMap((conversation) => [
{ conversationId: conversation.id, title: 'Task', status: 'running' },
{ conversationId: conversation.id, title: 'Task', status: 'waiting_approval' }
])
const summary = deriveConversationActivity(
conversations, tasks, new Set(conversations.map(({ id }) => id)), projects, 'Unknown'
)
expect(summary.activities.map(({ status }) => status)).toEqual(['question', 'approval', 'attention'])
expect(summary).toMatchObject({
running: 0, attention: 3,
byProjectId: { remote: { running: 0, attention: 2 }, local: { running: 0, attention: 1 } }
})
expect(deriveConversationActivity(conversations, [...tasks].reverse(), new Set(), projects, 'Unknown'))
.toEqual(summary)
})
it('includes background active IDs, streams, and tasks with unloaded conversation metadata', () => {
const summary = deriveConversationActivity([
{ id: 'active', title: 'Active', projectId: 'local', messages: [] },
{ id: 'stream', title: 'Stream', projectId: 'remote', messages: [{ state: 'streaming' }] },
{ id: 'known', title: 'Conversation title', messages: [] }
], [
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'running' },
{ conversationId: 'unloaded', projectId: 'remote', title: 'Task title', status: 'waiting_approval' },
{ conversationId: 'known', projectId: 'local', title: 'Wrong title', status: 'running' },
{ conversationId: 'missing-project', projectId: 'deleted', title: 'Missing project', status: 'running' }
], new Set(['active', 'no-metadata']), projects, 'Unknown')
expect(summary).toMatchObject({
running: 4, attention: 1,
byProjectId: {
local: { running: 1, attention: 0 },
remote: { running: 1, attention: 1 },
deleted: { running: 1, attention: 0 }
}
})
expect(summary.activities).toContainEqual({
conversationId: 'unloaded', projectId: 'remote', title: 'Task title', projectName: 'Remote', status: 'attention'
})
expect(summary.activities).toContainEqual({
conversationId: 'known', projectId: undefined, title: 'Conversation title', projectName: 'Unknown', status: 'running'
})
})
it('excludes idle, queued and terminal work and tasks without conversation IDs', () => {
const summary = deriveConversationActivity([
{ id: 'idle', title: 'Idle', messages: [{ state: 'complete', approval: null, pendingQuestions: [] }] }
], [
...['queued', 'completed', 'failed', 'interrupted', 'cancelled', 'paused'].map((status) => ({
conversationId: status, title: status, status
})),
{ title: 'No conversation', status: 'running' }
], new Set(), projects, 'Unknown')
expect(summary).toEqual({ activities: [], byProjectId: {}, running: 0, attention: 0 })
})
})
+104
View File
@@ -0,0 +1,104 @@
export type ConversationActivity = {
conversationId: string
projectId?: string
title: string
projectName: string
status: 'running' | 'approval' | 'question' | 'attention'
}
export type ConversationActivitySummary = {
activities: ConversationActivity[]
byProjectId: Record<string, { running: number; attention: number }>
running: number
attention: number
}
export function deriveConversationActivity(
conversations: readonly {
id: string
title: string
projectId?: string
messages: readonly { state?: string; approval?: unknown; pendingQuestions?: readonly unknown[] }[]
}[],
tasks: readonly {
conversationId?: string
projectId?: string
title: string
status: string
}[],
activeConversationIds: ReadonlySet<string>,
projects: readonly { id: string; name: string }[],
fallbackProjectName: string
): ConversationActivitySummary {
const projectNames = new Map(projects.map((project) => [project.id, project.name]))
const metadata = new Map(conversations.map((conversation) => [conversation.id, conversation]))
const rows = new Map<string, ConversationActivity>()
const priority = { running: 0, attention: 1, approval: 2, question: 3 }
for (const conversation of conversations) {
let status: ConversationActivity['status'] | undefined =
activeConversationIds.has(conversation.id) ? 'running' : undefined
for (const message of conversation.messages) {
const candidate = message.pendingQuestions?.length
? 'question'
: message.approval
? 'approval'
: message.state === 'streaming'
? 'running'
: undefined
if (candidate && (!status || priority[candidate] > priority[status])) {
status = candidate
}
}
if (status) {
rows.set(conversation.id, {
conversationId: conversation.id,
projectId: conversation.projectId,
title: conversation.title,
projectName: projectNames.get(conversation.projectId ?? '') ?? fallbackProjectName,
status
})
}
}
for (const task of tasks) {
if (!task.conversationId || (task.status !== 'running' && task.status !== 'waiting_approval')) {
continue
}
// Tasks use waiting_approval for both approvals and questions.
const status = task.status === 'waiting_approval' ? 'attention' : 'running'
const existing = rows.get(task.conversationId)
if (existing) {
if (priority[status] > priority[existing.status]) existing.status = status
continue
}
const conversation = metadata.get(task.conversationId)
const projectId = conversation ? conversation.projectId : task.projectId
rows.set(task.conversationId, {
conversationId: task.conversationId,
projectId,
title: conversation ? conversation.title : task.title,
projectName: projectNames.get(projectId ?? '') ?? fallbackProjectName,
status
})
}
const summary: ConversationActivitySummary = {
activities: [...rows.values()],
byProjectId: {},
running: 0,
attention: 0
}
const counts = new Map<string, { running: number; attention: number }>()
for (const activity of summary.activities) {
const category = activity.status === 'running' ? 'running' : 'attention'
summary[category] += 1
if (activity.projectId !== undefined) {
const project = counts.get(activity.projectId) ?? { running: 0, attention: 0 }
project[category] += 1
counts.set(activity.projectId, project)
}
}
summary.byProjectId = Object.fromEntries(counts)
return summary
}
+52
View File
@@ -0,0 +1,52 @@
.external-knowledge { display: flex; flex-direction: column; gap: var(--space-4); padding: var(--space-4); min-width: 0; min-height: 0; overflow: auto; }
.external-knowledge .external-knowledge { padding: 0; overflow: visible; }
.external-knowledge__columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-6); }
.external-knowledge__fields { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.external-knowledge__fields h3, .external-knowledge p, .external-knowledge-modal p { margin: 0; }
.external-knowledge__fields > button { align-self: flex-start; }
.external-knowledge__group { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; margin: 0; padding: var(--space-3) 0 0; border: 0; border-top: 1px solid var(--border-subtle); }
.external-knowledge__group legend { padding: 0 var(--space-2) 0 0; font-size: var(--font-section-title); font-weight: 650; }
.external-knowledge__help { color: var(--text-secondary); font-size: var(--font-caption); line-height: 1.5; }
.external-knowledge__help summary { cursor: pointer; }
.external-knowledge__help[open] summary { margin-bottom: var(--space-2); }
.external-knowledge__password { display: flex; align-items: center; gap: var(--space-2); }
.external-knowledge__password input { min-width: 0; flex: 1; }
.external-knowledge__password .icon-button { flex: 0 0 auto; }
.external-knowledge__instance-select > .model-button { width: 100%; }
.external-knowledge__instance-select > .runtime-picker__menu { position: static; width: 100%; }
.external-knowledge__instance-select > .runtime-picker__menu > button > small { white-space: normal; overflow-wrap: anywhere; }
.external-knowledge__footer { position: sticky; bottom: calc(-1 * var(--space-4)); padding: var(--space-3) 0; background: var(--surface-raised); border-top: 1px solid var(--border-subtle); justify-content: flex-end; }
.external-knowledge__footer > .scope-badge { margin-right: auto; }
.external-knowledge__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.external-knowledge__catalog { display: flex; flex-direction: column; gap: var(--space-2); }
.external-knowledge__target { display: flex; text-align: left; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); }
.external-knowledge__target[aria-pressed='true'] { background: var(--accent-subtle); border-color: var(--accent-selected); }
.external-knowledge__target span { min-width: 0; }
.external-knowledge__target small, .external-knowledge__instances small, .external-knowledge__results small { display: block; color: var(--text-secondary); overflow-wrap: anywhere; }
.external-knowledge__identifier, .external-knowledge__results p { overflow-wrap: anywhere; white-space: pre-wrap; }
.external-knowledge__results li { padding: var(--space-3); border-bottom: 1px solid var(--border-default); }
.external-knowledge__instances { list-style: none; padding: 0; margin: var(--space-3) 0 0; }
.external-knowledge__instances li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-default); }
.external-knowledge-modal { position: fixed; inset: 0; z-index: var(--z-dialog); display: grid; place-items: center; padding: var(--space-4); background: var(--overlay-backdrop); }
.external-knowledge-modal__surface { width: min(560px, 100%); max-height: calc(100dvh - 2 * var(--space-4)); min-width: 0; display: flex; flex-direction: column; background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); box-shadow: var(--shadow-dialog); overflow: hidden; }
.external-knowledge-modal__surface > header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-4); border-bottom: 1px solid var(--border-default); }
.external-knowledge-modal__surface h2 { margin: 0; font-size: var(--font-section-title); }
.external-knowledge-modal__surface > header > div { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.external-knowledge-modal__body { padding: var(--space-4); min-height: 0; overflow: auto; }
.external-knowledge, .external-knowledge-modal__body { scroll-padding-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
.external-knowledge__fieldset :is(input, select, textarea, summary) { scroll-margin-block: var(--space-3) calc(var(--control-height) + 2 * var(--space-4)); }
.external-knowledge-modal__body .external-knowledge__columns { gap: var(--space-3); }
.external-knowledge-modal__body .external-knowledge__fieldset { gap: var(--space-3); }
.external-knowledge dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2) var(--space-4); margin: 0; }
.external-knowledge dt { color: var(--text-secondary); }
.external-knowledge dd { margin: 0; overflow-wrap: anywhere; }
.external-knowledge > header h2 { margin: 0 0 var(--space-2); font-size: var(--font-section-title); }
.external-knowledge > .page-tabs { flex: 0 0 auto; }
.external-knowledge__fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }
.external-knowledge__filters { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3); flex: 0 0 auto; }
.external-knowledge > header, .external-knowledge-modal__surface > header { flex: 0 0 auto; }
.external-knowledge__target:hover:not(:disabled):not([aria-pressed='true']) { background: var(--surface-subtle); }
.external-knowledge__target:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.external-knowledge__target:disabled { opacity: 0.6; }
.external-knowledge__target strong, .external-knowledge__instances strong { overflow-wrap: anywhere; }
@media (max-width: 959px) { .external-knowledge__columns { grid-template-columns: minmax(0, 1fr); } }
@@ -2,9 +2,57 @@ import type { TranslationShape } from '../../resource-types'
import type { knowledge as chineseKnowledge } from '../zh-CN/knowledge' import type { knowledge as chineseKnowledge } from '../zh-CN/knowledge'
export const knowledge = { export const knowledge = {
external: {
close: 'Close instance manager', deleteInstanceTitle: 'Delete instance “{{name}}”?',
clearSearch: 'Clear search', noResultsHelp: 'Try a different name or clear the search to see available libraries.',
emptyHelp: 'Check access on the remote service, or enter a known library ID manually.',
sourceFilter: 'Library source filter', clearFilters: 'Clear filters', weightedScore: 'Weighted score',
saveThenTest: 'Save the instance, then use Test connection in the instance list.',
rebindConfirm: 'Save this binding with the newly selected remote library. Remote data will not change.',
probe: { untested: 'Not tested', 'catalog-ready': 'Catalog available', healthy: 'Connected', 'list-restricted': 'Catalog restricted; enter a library ID manually', 'auth-failed': 'Authentication failed', unreachable: 'Service unreachable', failed: 'Connection failed' },
credentials: { configured: 'Credential configured', missing: 'Credential missing', unavailable: 'Credential unavailable' },
manage: 'Manage external instances', instances: 'External instances', addInstance: 'Add instance',
type: 'Library type', local: 'Local', provider: 'Provider', instance: 'External instance',
editInstance: 'Edit instance', authentication: 'Authentication', credentialAction: 'Saved credential', connectionHelp: 'How to test the connection',
enabledHelp: 'Enabled bindings send retrieval queries to this service.',
address: 'Service URL', credential: 'API Key', keep: 'Keep saved credential', replace: 'Replace credential', clear: 'Clear credential',
credentialNote: 'Credentials are encrypted locally and are never returned after saving.', showKey: 'Show credential', hideKey: 'Hide credential',
clearConfirm: 'Clearing the credential makes libraries using this instance unavailable for retrieval.',
transportNote: 'This HTTP address sends credentials and queries without transport encryption.',
transportConfirm: 'I understand and allow credentials to be sent to this address', enabled: 'Enable instance',
testConnection: 'Test connection', saveInstance: 'Save instance', saved: 'Saved', removed: 'Removed from GoodBuddy',
noInstances: 'No external instances configured', noInstancesHelp: 'Add your existing Dify, FastGPT, or RAGFlow service.',
selectInstance: 'Select an external instance', remote: 'Remote library', remoteId: 'Remote library ID', remoteName: 'Remote name',
manual: 'Enter library ID manually', browse: 'Choose from catalog', search: 'Search remote libraries', refresh: 'Refresh library catalog',
loading: 'Loading libraries…', empty: 'No accessible libraries in this instance', noResults: 'No matching results',
next: 'Next page', previous: 'Previous page', root: 'Back to root', folder: 'Open folder', alreadyBound: 'Already added',
verify: 'Verify library', source: 'Library source', config: 'Retrieval settings', common: 'Common settings',
resultLimit: 'Returned snippets', timeout: 'Request timeout (ms)', snippetLimit: 'Maximum characters per snippet',
localOnly: 'Settings are saved only in GoodBuddy for subsequent retrievals through this binding. They do not change remote library settings.',
queryNote: 'Only the current query is sent to the selected instance. Files and full conversations are not uploaded, and no GoodBuddy model is called. Retrieval may incur remote Embedding, Rerank usage and API records.',
query: 'Test query', test: 'Test retrieval', testing: 'Testing…', tested: 'Retrieval succeeded with {{count}} snippets',
untested: 'Enter a query and successfully test retrieval before saving. Zero results also allow saving. Saving runs the query again to verify the current settings.',
add: 'Add library', saveConfig: 'Save retrieval settings', defaults: 'Restore defaults',
datasetDefaults: 'Use dataset defaults', datasetDefaultsHelp: 'Each request uses the current remote settings. Remote changes will affect results.',
searchMethod: 'Search method', topK: 'Provider Top K', thresholdEnabled: 'Enable score threshold', threshold: 'Similarity threshold',
rerank: 'Enable Rerank', rerankMode: 'Reranking mode', rerankProvider: 'Reranking provider', rerankModel: 'Reranking model',
keywordWeight: 'Keyword weight', vectorWeight: 'Vector weight', embeddingProvider: 'Embedding provider', embeddingModel: 'Embedding model',
tokenLimit: 'Token limit', knnTopK: 'KNN Top K', rerankId: 'Remote reranking model ID', rerankCandidates: 'Rerank candidates',
graph: 'Graph retrieval', graphHelp: 'Search existing remote graph content without creating or updating the graph.', graphUnknown: 'Completed remote graph not confirmed',
compilation: 'Include Knowledge Compilation', compilationUnavailable: 'Knowledge Compilation configuration not confirmed for this library',
overview: 'Overview', rebind: 'Change remote library', refreshDetail: 'Refresh remote details', remove: 'Remove from GoodBuddy',
removeBindingConfirm: 'Only the local binding and retrieval settings are deleted. Remote libraries and documents are preserved. Historical conversation citations remain readable.',
deleteInstance: 'Delete instance and local bindings', deleteInstanceConfirm: 'This instance is used by {{count}} libraries. Deletion also removes their local bindings. Remote data will not change.',
cancelRequest: 'Cancel request', remoteData: 'Knowledge stays on the remote service. Snippets used in answers are saved locally with the conversation.',
status: 'Status', credentialStatus: 'Credential status', boundCount: '{{count}} bindings', lastTest: 'Last test', never: 'Not retrieved yet',
snapshot: 'Historical citation snapshot', documentId: 'Remote document ID', chunkId: 'Remote snippet ID', providerScore: 'Provider score',
elapsed: 'Total {{ms}} ms', returned: '{{returned}} returned · {{kept}} retained', truncated: 'Results truncated',
allSources: 'All sources', searchLibraries: 'Search libraries', selectTarget: 'Select a remote library',
states: { ready: 'Remote retrieval ready', untested: 'Not tested', 'instance-disabled': 'Instance disabled', 'credential-error': 'Credential unavailable', 'remote-missing': 'Remote target missing', 'config-invalid': 'Settings need confirmation', 'temporarily-unavailable': 'Temporarily unavailable' }
},
page: { page: {
title: 'Knowledge Base', title: 'Knowledge Base',
description: 'Manage files, folders, and web sources, then inspect their indexes and graph.' description: 'Manage local knowledge and retrieve from existing enterprise knowledge services.'
}, },
actions: { actions: {
cancel: 'Cancel', cancel: 'Cancel',
@@ -2,6 +2,20 @@ import type { TranslationShape } from '../../resource-types'
import type { workspace as chineseWorkspace } from '../zh-CN/workspace' import type { workspace as chineseWorkspace } from '../zh-CN/workspace'
export const workspace = { export const workspace = {
projectActivity: {
title: 'All project activity',
close: 'Close project activity',
unassigned: 'No project',
attentionCount: '{{count}} need attention',
runningCount: '{{count}} running',
groups: { attention: 'Needs attention', running: 'Running' },
status: {
running: 'Running',
approval: 'Waiting for approval',
question: 'Waiting for your answer',
attention: 'Needs attention'
}
},
management: { management: {
gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory', gitWorkspace: 'Git Workspace', more: 'More actions for {{name}}', browse: 'Browse directory', parent: 'Parent directory',
createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties', createFile: 'New file', createDirectory: 'New directory', rename: 'Rename', move: 'Move', delete: 'Delete', properties: 'Properties',
@@ -506,6 +520,9 @@ export const workspace = {
empty: 'The workspace is empty.' empty: 'The workspace is empty.'
}, },
question: { question: {
task: 'Task: {{title}}',
pendingCount: '{{count}} pending questions. Answer them in order.',
locate: 'Go to question form',
title: 'OpenCode needs more information', title: 'OpenCode needs more information',
otherAnswer: 'Other answer', otherAnswer: 'Other answer',
answerPlaceholder: 'Enter your answer', answerPlaceholder: 'Enter your answer',
@@ -1,7 +1,55 @@
export const knowledge = { export const knowledge = {
external: {
close: '关闭实例管理', deleteInstanceTitle: '删除实例“{{name}}”?',
clearSearch: '清除搜索', noResultsHelp: '换一个名称搜索,或清除搜索查看可用知识库。',
emptyHelp: '请检查远端服务的访问权限,或手工填写已知的知识库 ID。',
sourceFilter: '知识库来源筛选', clearFilters: '清除筛选', weightedScore: '加权分数',
saveThenTest: '保存实例后,可在实例列表中测试连接。',
rebindConfirm: '将此绑定保存为新选择的远端知识库,远端数据不会改变。',
probe: { untested: '未测试', 'catalog-ready': '目录可用', healthy: '连接正常', 'list-restricted': '目录受限,可手工填写知识库 ID', 'auth-failed': '认证失败', unreachable: '服务无法连接', failed: '连接失败' },
credentials: { configured: '已配置凭据', missing: '缺少凭据', unavailable: '凭据不可用' },
manage: '管理外部实例', instances: '外部实例', addInstance: '添加实例',
type: '知识库类型', local: '本地', provider: 'Provider', instance: '外部实例',
editInstance: '编辑实例', authentication: '身份认证', credentialAction: '已保存凭据', connectionHelp: '如何测试连接',
enabledHelp: '启用后,关联知识库的检索查询将发送到此服务。',
address: '服务地址', credential: 'API Key', keep: '保留已保存凭据', replace: '替换凭据', clear: '清除凭据',
credentialNote: '凭据由本机加密保存,保存后不会回显。', showKey: '显示凭据', hideKey: '隐藏凭据',
clearConfirm: '清除凭据后,使用此实例的知识库将无法检索。',
transportNote: '此 HTTP 地址通过未加密连接传输凭据和查询。',
transportConfirm: '我了解并允许向此地址发送凭据', enabled: '启用实例',
testConnection: '测试连接', saveInstance: '保存实例', saved: '已保存', removed: '已从 GoodBuddy 移除',
noInstances: '尚未配置外部实例', noInstancesHelp: '添加企业现有的 Dify、FastGPT 或 RAGFlow 服务。',
selectInstance: '选择外部实例', remote: '远端知识库', remoteId: '远端知识库 ID', remoteName: '远端名称',
manual: '手工填写知识库 ID', browse: '从目录选择', search: '搜索远端知识库', refresh: '刷新知识库列表',
loading: '正在读取知识库…', empty: '当前实例没有可访问的知识库', noResults: '没有符合条件的结果',
next: '下一页', previous: '上一页', root: '返回根目录', folder: '打开文件夹', alreadyBound: '已添加',
verify: '验证知识库', source: '知识库来源', config: '检索配置', common: '通用配置',
resultLimit: '返回片段数', timeout: '请求超时(毫秒)', snippetLimit: '单片段字符上限',
localOnly: '参数仅保存在 GoodBuddy,用于此绑定的后续检索,不会修改远端知识库设置。',
queryNote: '只向所选实例发送当前查询,不上传文件或完整会话,不调用 GoodBuddy 模型。检索可能产生远端 Embedding、Rerank 用量及 API 使用记录。',
query: '测试查询', test: '测试检索', testing: '正在测试…', tested: '检索成功,返回 {{count}} 条片段',
untested: '请填写查询并成功测试检索后保存。零条结果也可保存。保存时会再次发送查询,验证当前参数。',
add: '添加知识库', saveConfig: '保存检索参数', defaults: '恢复默认',
datasetDefaults: '使用知识库默认配置', datasetDefaultsHelp: '每次检索沿用远端当前设置,远端设置变化会影响结果。',
searchMethod: '检索方式', topK: 'Provider Top K', thresholdEnabled: '启用分数阈值', threshold: '相似度阈值',
rerank: '启用 Rerank', rerankMode: '重排方式', rerankProvider: '重排模型提供商', rerankModel: '重排模型',
keywordWeight: '关键词权重', vectorWeight: '向量权重', embeddingProvider: 'Embedding 提供商', embeddingModel: 'Embedding 模型',
tokenLimit: 'Token 上限', knnTopK: 'KNN Top K', rerankId: '远端重排模型 ID', rerankCandidates: '重排候选数量',
graph: '图谱检索', graphHelp: '检索远端已生成的图谱,不会创建或更新图谱。', graphUnknown: '未确认远端图谱已完成',
compilation: '包含 Knowledge Compilation', compilationUnavailable: '当前知识库未确认配置 Knowledge Compilation',
overview: '概览', rebind: '更换远端知识库', refreshDetail: '刷新远端信息', remove: '从 GoodBuddy 移除',
removeBindingConfirm: '仅删除本机绑定和检索配置,不删除远端知识库或文档。历史会话中的引用继续可读。',
deleteInstance: '删除实例和本地绑定', deleteInstanceConfirm: '此实例被 {{count}} 个知识库使用。删除会同时移除本机绑定,远端数据不会改变。',
cancelRequest: '取消请求', remoteData: '知识内容保留在远端;回答使用的片段随会话保存在本机。',
status: '状态', credentialStatus: '凭据状态', boundCount: '{{count}} 个绑定', lastTest: '最近测试', never: '尚未检索',
snapshot: '历史引用快照', documentId: '远端文档 ID', chunkId: '远端片段 ID', providerScore: 'Provider 分数',
elapsed: '总耗时 {{ms}} ms', returned: '返回 {{returned}} 条 · 保留 {{kept}} 条', truncated: '结果已截断',
allSources: '全部来源', searchLibraries: '搜索知识库', selectTarget: '选择远端知识库',
states: { ready: '远端可检索', untested: '未测试', 'instance-disabled': '实例已停用', 'credential-error': '凭据不可用', 'remote-missing': '远端目标不存在', 'config-invalid': '配置需要确认', 'temporarily-unavailable': '暂时无法连接' }
},
page: { page: {
title: '知识库', title: '知识库',
description: '管理文件、目录和网页来源,并查看索引与图谱。' description: '管理本地知识,并检索企业现有知识平台。'
}, },
actions: { actions: {
cancel: '取消', cancel: '取消',
@@ -1,4 +1,18 @@
export const workspace = { export const workspace = {
projectActivity: {
title: '全项目活动',
close: '关闭全项目活动',
unassigned: '未归属项目',
attentionCount: '{{count}} 个待处理',
runningCount: '{{count}} 个运行中',
groups: { attention: '待处理', running: '运行中' },
status: {
running: '运行中',
approval: '等待审批',
question: '等待你的回答',
attention: '待处理'
}
},
management: { management: {
gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录', gitWorkspace: 'Git 工作区', more: '{{name}} 的更多操作', browse: '浏览此目录', parent: '返回父目录',
createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性', createFile: '新建文件', createDirectory: '新建目录', rename: '重命名', move: '移动', delete: '删除', properties: '属性',
@@ -493,6 +507,9 @@ export const workspace = {
empty: '工作区为空。' empty: '工作区为空。'
}, },
question: { question: {
task: '任务:{{title}}',
pendingCount: '还有 {{count}} 个待答问题,请依次回答。',
locate: '前往问答表单',
title: 'OpenCode 需要补充信息', title: 'OpenCode 需要补充信息',
otherAnswer: '其他回答', otherAnswer: '其他回答',
answerPlaceholder: '输入你的回答', answerPlaceholder: '输入你的回答',
+161
View File
@@ -0,0 +1,161 @@
.project-activity__summary,
.project-activity__row {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-width: 0;
min-height: 32px;
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background var(--motion-fast) ease-out;
}
.project-activity__summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
flex-shrink: 0;
row-gap: var(--space-1);
font-size: var(--font-caption);
}
.project-activity__summary > span { grid-column: 1; }
.project-activity__summary > span:first-child { color: var(--text-secondary); }
.project-activity__summary > svg { grid-column: 2; grid-row: 1 / 3; }
.project-activity__summary > svg,
.project-activity__row > svg {
flex: 0 0 auto;
margin-left: auto;
color: var(--text-secondary);
}
.project-activity__summary:hover,
.project-activity__row:hover {
background: var(--surface-subtle);
}
.project-activity__summary:active,
.project-activity__row:active {
background: var(--accent-subtle);
}
.project-activity__summary:focus-visible,
.project-activity__dialog button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.project-activity__counts,
.project-activity__counts > span {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.project-activity__counts {
flex-wrap: wrap;
gap: var(--space-2);
font-size: var(--font-caption);
}
.project-activity__attention { color: var(--warning); }
.project-activity__running { color: var(--accent); }
.project-switcher__project-heading {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.project-switcher__project-heading > b { min-width: 0; }
.conversation-activity-indicator.conversation-activity-indicator--attention {
background: var(--warning);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--warning) 14%, transparent);
animation: none;
}
.project-activity__backdrop {
position: fixed;
inset: 0;
z-index: var(--z-dialog);
display: grid;
place-items: center;
padding: var(--space-4);
background: var(--overlay-backdrop);
}
.project-activity__dialog {
display: flex;
flex-direction: column;
width: min(100%, 520px);
max-height: 100%;
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
color: var(--text-primary);
box-shadow: var(--shadow-dialog);
font-size: var(--font-body);
}
.project-activity__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
flex: 0 0 auto;
}
.project-activity__header h2 {
margin: 0;
font-size: var(--font-section-title);
}
.project-activity__content {
overflow-y: auto;
padding: var(--space-3);
}
.project-activity__content h3 {
margin: var(--space-2);
color: var(--text-secondary);
font-size: var(--font-caption);
}
.project-activity__content section + section { margin-top: var(--space-4); }
.project-activity__content ul { margin: 0; padding: 0; list-style: none; }
.project-activity__identity {
display: grid;
gap: var(--space-1);
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.project-activity__identity small {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.project-activity__row > .project-activity__attention,
.project-activity__row > .project-activity__running {
max-width: 40%;
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.project-activity__summary,
.project-activity__row { transition: none; }
}
+22 -4
View File
@@ -2890,7 +2890,7 @@ textarea:focus-visible {
padding: 14px; padding: 14px;
border: 1px solid var(--border-default); border: 1px solid var(--border-default);
border-radius: var(--radius-card); border-radius: var(--radius-card);
overflow-y: auto; overflow: hidden;
background: var(--surface-raised); background: var(--surface-raised);
box-shadow: var(--shadow-dialog); box-shadow: var(--shadow-dialog);
flex-direction: column; flex-direction: column;
@@ -2898,7 +2898,7 @@ textarea:focus-visible {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.project-create-card header { .project-create-card > header {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
@@ -2909,13 +2909,29 @@ textarea:focus-visible {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.project-create-card__close { .project-create-card .project-create-card__close {
position: relative; position: relative;
z-index: 2; z-index: 2;
width: 40px;
height: 40px;
flex: 0 0 auto; flex: 0 0 auto;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.project-create-card__body {
display: flex;
min-height: 0;
padding: 3px;
overflow-y: auto;
overscroll-behavior: contain;
flex-direction: column;
gap: 10px;
}
.project-create-card__body > * {
flex-shrink: 0;
}
.project-create-card label { .project-create-card label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2962,6 +2978,8 @@ textarea:focus-visible {
.project-create-card__actions { .project-create-card__actions {
display: flex; display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 7px; gap: 7px;
} }
@@ -3787,7 +3805,7 @@ button > svg {
} }
:is( :is(
.project-create-card, .project-create-card__body,
.knowledge-citation-dialog__surface, .knowledge-citation-dialog__surface,
.channel-qr-dialog, .channel-qr-dialog,
.document-parsing-diagnostic, .document-parsing-diagnostic,
+4 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
import { agentRuntimeSelectionSchema } from './runtime-selection-contracts' import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
import { sshHostIdSchema } from './ssh-host-contracts' import { sshHostIdSchema } from './ssh-host-contracts'
@@ -368,7 +369,9 @@ export const conversationMessageSchema = z
.object({ .object({
libraryId: assistantIdSchema, libraryId: assistantIdSchema,
libraryName: z.string().max(200), libraryName: z.string().max(200),
documentId: assistantIdSchema, documentId: assistantIdSchema.optional(),
external: externalKnowledgeLocatorSchema.optional(),
warnings: z.array(z.string().max(500)).max(20).optional(),
chunkId: assistantIdSchema.optional(), chunkId: assistantIdSchema.optional(),
documentName: z.string().max(500), documentName: z.string().max(500),
sourceName: z.string().max(500), sourceName: z.string().max(500),
+21 -1
View File
@@ -1308,6 +1308,7 @@ export type AgentEvent =
requestId: string requestId: string
type: 'question' type: 'question'
questionId: string questionId: string
childTaskId?: string
questions: Array<{ questions: Array<{
header: string header: string
question: string question: string
@@ -1319,6 +1320,11 @@ export type AgentEvent =
custom: boolean custom: boolean
}> }>
} }
| {
requestId: string
type: 'question-resolved'
questionId: string
}
| { | {
requestId: string requestId: string
type: 'artifact' type: 'artifact'
@@ -1633,6 +1639,8 @@ export const knowledgeRelationInputSchema = z
.strict() .strict()
export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & { export type KnowledgeLibrary = z.infer<typeof knowledgeCreateSchema> & {
kind?: 'local' | 'external'
external?: import('./external-knowledge-contracts').ExternalKnowledgeBinding
id: string id: string
sourceCount: number sourceCount: number
documentCount: number documentCount: number
@@ -1717,9 +1725,11 @@ export type KnowledgeSnapshot = {
} }
export type KnowledgeSearchReference = { export type KnowledgeSearchReference = {
warnings?: string[]
external?: import('./external-knowledge-contracts').ExternalKnowledgeLocator
libraryId: string libraryId: string
libraryName: string libraryName: string
documentId: string documentId?: string
chunkId?: string chunkId?: string
documentName: string documentName: string
sourceName: string sourceName: string
@@ -2274,6 +2284,16 @@ export type DesktopApi = {
onTodoStatusChanged: (listener: () => void) => () => void onTodoStatusChanged: (listener: () => void) => () => void
} }
knowledge: { knowledge: {
externalInstancesList: () => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary[]>
externalInstancesSave: (input: import('./external-knowledge-contracts').ExternalKnowledgeInstanceSaveInput) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
externalInstancesTest: (input: {instanceId:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
externalInstancesSetEnabled: (input: {instanceId:string;enabled:boolean}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeInstanceSummary>
externalInstancesDelete: (input: {instanceId:string}) => Promise<void>
externalCatalogList: (input: {instanceId:string;page?:number;pageSize?:number;parentId?:string|null;search?:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeCatalogPage>
externalCatalogGet: (input: {instanceId:string;remoteKnowledgeBaseId:string}) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeCatalogItem>
externalBindingsCreate: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingSaveInput) => Promise<KnowledgeSnapshot>
externalBindingsUpdate: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingSaveInput & {knowledgeBaseId:string}) => Promise<KnowledgeSnapshot>
externalRetrievalTest: (input: import('./external-knowledge-contracts').ExternalKnowledgeBindingTestInput) => Promise<import('./external-knowledge-contracts').ExternalKnowledgeTestResult>
getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot> getSnapshot: (libraryId?: string) => Promise<KnowledgeSnapshot>
createLibrary: ( createLibrary: (
input: z.infer<typeof knowledgeCreateSchema> input: z.infer<typeof knowledgeCreateSchema>
@@ -0,0 +1,52 @@
import { z } from 'zod'
const id = z.string().trim().min(1).max(512)
export const externalKnowledgeProviderSchema = z.enum(['dify', 'fastgpt', 'ragflow'])
export type ExternalKnowledgeProvider = z.infer<typeof externalKnowledgeProviderSchema>
export const externalKnowledgeCommonConfigSchema = z.object({
resultLimit: z.number().int().min(1).max(20).default(6),
requestTimeoutMs: z.number().int().min(1000).max(60000).default(15000),
maxSnippetCharacters: z.number().int().min(100).max(8000).default(4000)
}).strict()
export type ExternalKnowledgeCommonConfig = z.infer<typeof externalKnowledgeCommonConfigSchema>
const retrievalModel = z.object({
search_method: z.enum(['keyword_search', 'semantic_search', 'full_text_search', 'hybrid_search']),
reranking_enable: z.boolean(), top_k: z.number().int().min(1).max(20),
score_threshold_enabled: z.boolean(), score_threshold: z.number().min(0).max(1).optional(),
reranking_mode: z.enum(['reranking_model', 'weighted_score']).optional(),
reranking_model: z.object({reranking_provider_name: id, reranking_model_name: id}).strict().optional(),
weights: z.object({weight_type: z.literal('customized'), vector_setting: z.object({vector_weight: z.number().min(0).max(1), embedding_provider_name: id, embedding_model_name: id}).strict(), keyword_setting: z.object({keyword_weight: z.number().min(0).max(1)}).strict()}).strict().optional()
}).strict().superRefine((value, ctx) => {
if (value.score_threshold_enabled && value.score_threshold === undefined) ctx.addIssue({code:'custom',message:'Score threshold is required'})
if (value.reranking_enable && (!value.reranking_mode || (value.reranking_mode === 'reranking_model' ? !value.reranking_model : !value.weights))) ctx.addIssue({code:'custom',message:'Complete reranking configuration is required'})
})
export const externalKnowledgeProviderConfigSchema = z.union([
z.object({provider:z.literal('dify'),useDatasetDefaults:z.literal(true)}).strict(),
z.object({provider:z.literal('dify'),useDatasetDefaults:z.literal(false),retrievalModel}).strict(),
z.object({provider:z.literal('fastgpt'),searchMode:z.enum(['embedding','fullTextRecall','mixedRecall']),tokenLimit:z.number().int().min(1).max(30000),similarity:z.number().min(0).max(1),usingRerank:z.boolean()}).strict(),
z.object({provider:z.literal('ragflow'),similarityThreshold:z.number().min(0).max(1),vectorSimilarityWeight:z.number().min(0).max(1),knnTopK:z.number().int().min(1).max(2048),useKg:z.boolean(),includeKnowledgeCompilation:z.boolean()}).strict()
])
export type ExternalKnowledgeProviderConfig = z.infer<typeof externalKnowledgeProviderConfigSchema>
export const externalKnowledgeInstanceSaveInputSchema = z.object({
id:id.optional(),name:z.string().trim().min(1).max(512),provider:externalKnowledgeProviderSchema,
baseUrl:z.string().trim().url().max(8192).refine((value)=>{const url=new URL(value);return ['http:','https:'].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash},'Invalid service URL'),
enabled:z.boolean(),credential:z.discriminatedUnion('action',[z.object({action:z.literal('keep')}).strict(),z.object({action:z.literal('clear')}).strict(),z.object({action:z.literal('replace'),value:z.string().trim().min(1).max(16384)}).strict()])
}).strict()
export type ExternalKnowledgeInstanceSaveInput = z.infer<typeof externalKnowledgeInstanceSaveInputSchema>
export const externalKnowledgeInstanceInputSchema = z.object({instanceId:id}).strict()
export const externalKnowledgeInstanceEnabledInputSchema = externalKnowledgeInstanceInputSchema.extend({enabled:z.boolean()})
export const externalKnowledgeCatalogListInputSchema = externalKnowledgeInstanceInputSchema.extend({page:z.number().int().min(1).max(500).optional(),pageSize:z.number().int().min(1).max(100).optional(),parentId:id.nullable().optional(),search:z.string().trim().max(512).optional()})
export const externalKnowledgeCatalogGetInputSchema = externalKnowledgeInstanceInputSchema.extend({remoteKnowledgeBaseId:id})
export const externalKnowledgeBindingTestInputSchema = externalKnowledgeCatalogGetInputSchema.extend({commonConfig:externalKnowledgeCommonConfigSchema,providerConfig:externalKnowledgeProviderConfigSchema,testQuery:z.string().trim().min(1).max(4000)})
export const externalKnowledgeBindingSaveInputSchema = externalKnowledgeBindingTestInputSchema.extend({name:z.string().trim().min(1).max(512),description:z.string().max(4000).optional(),remoteName:z.string().trim().min(1).max(512)})
export const externalKnowledgeBindingUpdateInputSchema = externalKnowledgeBindingSaveInputSchema.extend({knowledgeBaseId:id})
export type ExternalKnowledgeBindingTestInput = z.infer<typeof externalKnowledgeBindingTestInputSchema>
export type ExternalKnowledgeBindingSaveInput = z.infer<typeof externalKnowledgeBindingSaveInputSchema>
export type ExternalKnowledgeInstanceSummary = {id:string;name:string;provider:ExternalKnowledgeProvider;baseUrl:string;enabled:boolean;credentialStatus:'configured'|'missing'|'unavailable';probeStatus:'untested'|'catalog-ready'|'healthy'|'list-restricted'|'auth-failed'|'unreachable'|'failed';bindingCount:number;lastTestedAt?:string;lastErrorCode?:string}
export type ExternalKnowledgeCatalogItem = {id:string;name:string;description?:string;kind?:'dataset'|'folder';graphEnabled?:boolean;knowledgeCompilationEnabled?:boolean}
export type ExternalKnowledgeCatalogPage = {items:ExternalKnowledgeCatalogItem[];total?:number;hasMore:boolean}
export type ExternalKnowledgeRemoteResult = {documentTitle:string;sourceDisplayName:string;snippet:string;providerScore?:number;providerScores?:{type:string;value:number;index?:number}[];remoteDocumentId?:string;remoteChunkId?:string;location?:string;sourceUrl?:string}
export type ExternalKnowledgeTestResult = {results:ExternalKnowledgeRemoteResult[];durationMs:number}
export type ExternalKnowledgeBinding = {knowledgeBaseId:string;instanceId:string;provider:ExternalKnowledgeProvider;remoteKnowledgeBaseId:string;remoteName:string;commonConfig:ExternalKnowledgeCommonConfig;providerConfig:ExternalKnowledgeProviderConfig;lastVerifiedAt:string}
export const externalKnowledgeLocatorSchema = z.object({kind:z.literal('external'),provider:externalKnowledgeProviderSchema,instanceId:id,remoteKnowledgeBaseId:id,remoteDocumentId:id.optional(),remoteChunkId:id.optional(),providerScore:z.number().finite().optional(),providerScores:z.array(z.object({type:z.string().max(128),value:z.number().finite(),index:z.number().int().optional()}).strict()).max(20).optional(),location:z.string().max(8192).optional(),sourceUrl:z.string().max(8192).optional()}).strict()
export type ExternalKnowledgeLocator = z.infer<typeof externalKnowledgeLocatorSchema>
+10
View File
@@ -1,4 +1,14 @@
export const ipcChannels = { export const ipcChannels = {
externalInstancesList: 'knowledge:external-instances:list',
externalInstancesSave: 'knowledge:external-instances:save',
externalInstancesTest: 'knowledge:external-instances:test',
externalInstancesSetEnabled: 'knowledge:external-instances:set-enabled',
externalInstancesDelete: 'knowledge:external-instances:delete',
externalCatalogList: 'knowledge:external-catalog:list',
externalCatalogGet: 'knowledge:external-catalog:get',
externalBindingsCreate: 'knowledge:external-bindings:create',
externalBindingsUpdate: 'knowledge:external-bindings:update',
externalRetrievalTest: 'knowledge:external-retrieval:test',
appInfo: 'app:get-info', appInfo: 'app:get-info',
appShow: 'app:show', appShow: 'app:show',
appHide: 'app:hide', appHide: 'app:hide',
+10 -6
View File
@@ -1,4 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
import { import {
defaultKnowledgeOntologySettings, defaultKnowledgeOntologySettings,
knowledgeOntologySettingsSchema knowledgeOntologySettingsSchema
@@ -148,20 +149,21 @@ const channelScoresSchema = z
export const knowledgeRetrievalResultSchema = z export const knowledgeRetrievalResultSchema = z
.object({ .object({
knowledgeBaseId: idSchema, knowledgeBaseId: idSchema,
documentId: idSchema, documentId: idSchema.optional(),
sourceId: idSchema, sourceId: idSchema.optional(),
chunkId: idSchema, chunkId: idSchema.optional(),
external: externalKnowledgeLocatorSchema.optional(),
parentChunkId: idSchema.optional(), parentChunkId: idSchema.optional(),
documentTitle: z.string().max(512), documentTitle: z.string().max(512),
sourceDisplayName: z.string().max(512), sourceDisplayName: z.string().max(512),
sourceType: z.enum(['file', 'directory', 'url']), sourceType: z.enum(['file', 'directory', 'url']).optional(),
heading: z.string().max(512).optional(), heading: z.string().max(512).optional(),
location: z.string().max(8_192).optional(), location: z.string().max(8_192).optional(),
snippet: z.string().max(8_000), snippet: z.string().max(8_000),
relevance: z.number().finite().min(0).max(1), relevance: z.number().finite().min(0).max(1).optional(),
rank: z.number().int().positive().max(20), rank: z.number().int().positive().max(20),
preRerankRank: optionalRankSchema, preRerankRank: optionalRankSchema,
channels: z.array(knowledgeRetrievalChannelSchema).min(1).max(4), channels: z.array(knowledgeRetrievalChannelSchema).max(4),
scores: channelScoresSchema scores: channelScoresSchema
}) })
.strict() .strict()
@@ -200,6 +202,8 @@ const channelTimingSchema = z
export const knowledgeRetrievalDiagnosticsSchema = z export const knowledgeRetrievalDiagnosticsSchema = z
.object({ .object({
external: z.object({provider:z.enum(['dify','fastgpt','ragflow']),instanceId:z.string(),remoteKnowledgeBaseId:z.string()}).strict().optional(),
failure: z.string().max(500).optional(),
requestedChannels: z.array(knowledgeRetrievalChannelSchema).max(4), requestedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
usedChannels: z.array(knowledgeRetrievalChannelSchema).max(4), usedChannels: z.array(knowledgeRetrievalChannelSchema).max(4),
degradedChannels: z degradedChannels: z
+30
View File
@@ -0,0 +1,30 @@
import type { KnowledgeSearchReference } from './contracts'
import { stripKnowledgeHighlightTags } from './knowledge-text'
import type { KnowledgeRetrievalResponse } from './knowledge-contracts'
export function knowledgeReferenceKey(reference: KnowledgeSearchReference): string {
const external = reference.external
return JSON.stringify([
reference.libraryId, reference.documentId, reference.chunkId,
external?.provider, external?.instanceId, external?.remoteKnowledgeBaseId,
external?.remoteDocumentId, external?.remoteChunkId,
external?.location ?? reference.locator, external?.sourceUrl, reference.snippet
])
}
export function toKnowledgeReference(
result: KnowledgeRetrievalResponse['results'][number], libraryName: string
): KnowledgeSearchReference {
return {
libraryId: result.knowledgeBaseId, libraryName: libraryName.slice(0, 200),
documentId: result.documentId, chunkId: result.chunkId,
documentName: result.documentTitle.slice(0, 500), sourceName: result.sourceDisplayName.slice(0, 500),
locator: (result.external?.location ?? result.location)?.slice(0, 1000),
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 16000),
rank: result.rank, external: result.external,
score: result.external ? undefined : result.scores.fusedScore,
lexicalRank: result.scores.ftsRank, vectorRank: result.scores.vectorRank,
graphRank: result.scores.graphRank, similarity: result.scores.vectorSimilarity,
retrievalChannels: result.channels
}
}
+1
View File
@@ -317,6 +317,7 @@ export const remoteSemanticTranscriptPageResultSchema = z
acknowledgedSequence: agentSequenceSchema, acknowledgedSequence: agentSequenceSchema,
state: remoteOwnedPromptStateSchema, state: remoteOwnedPromptStateSchema,
sessionId: agentIdentifierSchema.optional(), sessionId: agentIdentifierSchema.optional(),
pendingQuestions: z.array(jsonValueSchema).optional(),
hasMore: z.boolean() hasMore: z.boolean()
}) })
.strict() .strict()

Some files were not shown because too many files have changed in this diff Show More