diff --git a/BUILD.md b/BUILD.md index 298a7556..f0c40536 100644 --- a/BUILD.md +++ b/BUILD.md @@ -31,6 +31,10 @@ npm start ## 质量验证 +跨功能测试方法见[质量验证索引](./docs/quality/README.md)。桌面界面的测试分层、 +原生键盘、截图与计算样式取证、隔离启动和脱敏报告遵循 +[Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md)。 + 提交或打包前运行: ```bash diff --git a/FEATURES.md b/FEATURES.md index 85016f00..31d7a84e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -282,7 +282,9 @@ otherwise. pages; the selected model's context window and bounded transports still apply. - [x] **Native Runtime interaction routing**: OpenCode and Continue questions 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 across rounds and local conversation reloads; answers discarded by older versions cannot be recovered. diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 5aef5b55..ab80a3c8 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -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] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [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] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。 - [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index ba887f83..87a3417e 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -283,6 +283,9 @@ - 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。 - 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 +- 项目选择器下方承载跨项目活动汇总,空闲时隐藏;项目计数与活动对话框遵守 + [项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。 + 活动数量不改变项目菜单的稳定分组和顺序。 - 桌面停靠状态下,主侧栏宽度允许在 `220px` 至 `420px` 范围内通过指针或键盘调整, 并记住用户选择;分隔条使用 `separator` 语义,支持方向键、Home 和 End。小于 `900px` 时继续使用固定宽度抽屉,不显示拖拽分隔条。 @@ -705,6 +708,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 ### 13.8 设置中心 - 全页设置使用固定标题区、左侧分类导航和独立滚动的内容区。右上角关闭按钮是离开设置中心的稳定入口。 +- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。 - 左侧分类导航在宽屏使用 `220px`,中等窗口使用 `196px`,窄窗口转为横向滚动;纵向滚动条仅在内容溢出时占用右侧空间,不在左侧创建镜像预留,选项与左侧可见边界保持默认内距。分类标题使用正文级字号,分类说明使用辅助字号;右侧内容区在可用空间内流式伸缩,最大宽度使用 `standard` 壳层的 `960px`,不得以页面专属较窄宽度压缩表单。 - 全页设置标题区与双栏内容使用共享 `--page-gutter`,标题、分类导航和内容区在同一页面边距基线上;标题继续使用标准 `PageHeader` 的底部分隔线与下内距。固定标题和双独立滚动区域不改变这些一级页面壳层规则;模态设置保留自身外框与标题边界。 - 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。 @@ -805,6 +809,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 ### 15.5 验收 +执行与取证方法见 [Electron UI 自动化测试规范](./docs/quality/electron-ui-automation.md),包括真实 Portal、原生键盘、计算样式与 PNG 审阅;视觉 fixture 与完整生产 App 的结果须分别标注。 + - [ ] 在浅色与深色主题下检查所有页面、弹窗、菜单、输入框、表格和空状态。 - [ ] 在大于等于 `1200px`、`960px`、`720px` 和小于 `720px` 的窗口宽度检查布局。 - [ ] 仅使用键盘完成导航、筛选、创建、编辑、确认和取消。 diff --git a/docs/features/application-tool-navigation/README.md b/docs/features/application-tool-navigation/README.md index 82b3f0a2..88b9b2c0 100644 --- a/docs/features/application-tool-navigation/README.md +++ b/docs/features/application-tool-navigation/README.md @@ -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 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。 六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为 演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。 +这些方案用于讨论最底部应用中心;其中商店页、商店应用安装与 F 方案属于未来交互参考, +不纳入首期实现或验收。首期产品只提供内置应用;Demo 暂保留远期示例以便后续讨论。 底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、 知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名 附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止, 以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。 这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。 +按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧 +工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。 六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用 上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理” @@ -56,7 +69,7 @@ Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快 | --- | --- | | 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 | | 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 | -| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,从侧栏底部打开助手工作栏中的对应面板 | +| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,由右侧助手工作栏 Tab 承载 | | 插件应用 | 未来由插件提供的一级页面入口 | | 插件工具 | 未来由插件提供的辅助面板或命令入口 | | 快捷入口 | 只负责打开或聚焦能力,不复制该能力的界面和业务状态 | diff --git a/docs/features/application-tool-navigation/logic-design.md b/docs/features/application-tool-navigation/logic-design.md index 74f2affa..cb5f3d1e 100644 --- a/docs/features/application-tool-navigation/logic-design.md +++ b/docs/features/application-tool-navigation/logic-design.md @@ -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 和未读标记本身不构成活动。活动结束且没有其他 +运行或等待条件时移除该行,不能因历史任务或未读状态继续计数。 diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md index e733cb86..499952d1 100644 --- a/docs/features/application-tool-navigation/prd.md +++ b/docs/features/application-tool-navigation/prd.md @@ -12,6 +12,9 @@ ## 1. 背景 +计划修订说明:当前实施范围以[第 8 节](#8-分期)为准。本文其余章节仍包含 v0.2 的 +左下角系统工具方案;启动实现前须同步功能要求、验收及配套设计,旧快捷工具条不再列入实施。 + 主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组, 底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端 和浏览器则需要先找到右侧助手工作栏后才能打开。 @@ -48,7 +51,8 @@ ## 4. 非目标 -- 首期不提供第三方插件搜索、下载、安装、更新、签名验证或卸载。 +- 首期不提供应用商店页面、商店导航、推荐位或安装按钮,也不提供第三方插件搜索、下载、 + 安装、更新、签名验证或卸载。内置应用的浏览、搜索与打开属于应用中心,不依赖商店。 - 不把文件树、终端或浏览器重新实现到左侧栏。 - 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。 - 不允许隐藏运行记录、当前对话或设置。 @@ -135,6 +139,17 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定 窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时 收起文字和非必要分隔,不隐藏四个系统工具或设置。 +### FR-14 项目活动汇总 + +用户在任一项目中都能查看跨项目的运行中与待处理会话,并从活动列表直接进入所选会话。 +没有活动时隐藏汇总入口;项目菜单保留原有分组与顺序,并显示各项目的活动数量。 +汇总不受当前项目、搜索条件或最近历史条数限制。计数与排除规则见 +[活动逻辑](./logic-design.md#9-项目活动汇总),交互见[活动界面](./ui-design.md#10-项目活动汇总)。 + +验收覆盖跨项目后台运行、同会话多任务去重、提问与审批优先、较早的仍在运行或等待处理的 +会话可见,以及从带搜索条件的页面或有未保存修改的设置页跳转。结束后仅有未读标记的 +会话不保留在活动汇总中;已完成未读面板不属于本项交付。 + ## 6. 权限与安全 - 快捷入口不授予新的文件、命令、网络或浏览器权限。 @@ -163,15 +178,44 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定 ### 首期 -- 重构侧栏底部,增加四个系统工具按钮和独立设置按钮。 -- 建立从 App 到助手工作栏的“打开或聚焦工具”意图接口。 -- 增加本机资源快照采集与资源监控单实例面板。 -- 增加内置应用的主导航显示设置。 +- 将侧栏最底部改为内置应用中心入口,设置按钮保持独立;具体列表、宫格或卡片样式从 + Demo 中收敛确定。 +- 应用中心提供魔法笔记、知识库、智能心跳的浏览、搜索与打开,不要求安装。 +- 支持应用常驻主导航、取消常驻和排序;未常驻应用仍可从应用中心打开,隐藏不删除数据或停止后台任务。 +- 应用中心和应用页面就近提供已有功能设置,共享真实设置来源;已有全局设置入口定位到同一界面。 + 魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。 +- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。 +- 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。 - 保持运行记录为固定系统入口。 -### 后续 +首期验收需覆盖:无商店入口时可以访问全部内置应用;取消常驻后仍可重新打开和配置; +不同设置入口状态一致;常驻排序重启后保留;右侧工具切换不改变当前应用页面。 +本节是范围调整,Demo 交互通过不代表正式功能已经实现。 -- 插件应用和插件工具注册。 -- 插件工具固定、取消固定和排序。 -- 插件市场、权限详情、安装和卸载。 +### 补充要求:魔法笔记迁移与入口联动 + +- 将现有魔法笔记设置迁入应用中心的应用设置界面,笔记页面内的设置入口访问同一份配置。 + 魔法笔记可常驻主导航;原全局设置位置如保留入口,应定位到共同设置界面。 +- 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。 + AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。 + 新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。 +- 应用关闭后的入口联动必须覆盖主导航和会话页面,不能只更新应用中心卡片或一个菜单。 + 知识库关闭后,左侧知识库菜单及会话中的知识库入口按钮同步移除;重新开启后按配置恢复。 +- “取消常驻”只移除主导航捷径;“关闭应用”移除该应用在主导航、会话等业务界面的入口。 + 应用中心保留管理和重新开启入口。原型中仅有常驻控制,正式设计需补齐这两种状态,避免混用。 +- 入口变化不删除已有笔记或知识数据。知识库已打开页面、会话已关联知识以及后台任务的处理, + 在详细逻辑同步时分别定义,不能仅凭隐藏按钮推断功能或后台行为已经停用。 + +补充验收:从任一配置入口关闭知识库,主导航和当前会话按钮立即同步移除,切换会话、 +重启后也不重新出现;重新开启后恢复相应入口。验证魔法笔记未配置时的默认开启,以及 +已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。 + +### 后续:应用商店与扩展 + +- 单独设计应用商店的目录、搜索、推荐、详情及安装管理,时间与具体范围另行确定。 +- 插件应用和插件工具注册,以及安装、更新、卸载与权限说明。 +- 插件应用可常驻主导航;插件工具按工作栏扩展设计接入,不恢复左下角重复系统工具条。 - 用户级快捷键与命令面板接入同一贡献命令。 + +现有 Demo 的商店页、项目管理和阅读空间安装流程仅保留为未来交互参考,不纳入首期验收, +也不作为已有商店能力的说明。首期实现不预建插件安装服务或远程目录协议。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index 67b35439..7b634e78 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -371,13 +371,38 @@ resourceMonitor.getLocalSnapshot(input: { ## 11. 实施顺序 -1. 增加应用导航设置 schema、迁移和设置界面。 -2. 按即时保存结果组合主导航,并补充失败恢复。 -3. 增加资源快照契约、Main 采集服务和 Preload API。 -4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。 -5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。 -6. 为工作栏增加带上下文的 `openOrActivate` 意图接口,以及浏览器和终端匹配规则。 -7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。 -8. 完成 Renderer、契约、迁移和跨平台回归测试。 +实施范围以 [PRD 分期](./prd.md#8-分期)为准。本节替代旧实施顺序;前文关于四个左侧 +系统工具按钮、仅持久化隐藏 ID 的契约需在实现前同步,不能直接按旧规格开发。 -插件注册与插件市场不与首期改动一并实施。 +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。刷新失败或会话已不存在时显示通知,不回退到项目最新会话。 + 设置离开检查通过后才提交项目、会话及界面清理状态。 +- 远程恢复成功提示沿用独立的自动消失计时器;活动汇总更新不重置或替代该计时器。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index 59ded961..0d947567 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -19,6 +19,7 @@ ```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、关闭按钮、点击遮罩关闭及焦点恢复; + 窄窗口限制宽高并在内容区滚动。活动全部结束时关闭对话框并隐藏入口。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index c1919d3d..83652f26 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -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 浏览器租用标签页关闭与显式恢复 - 更新 `tests/browser-tabs-electron-e2e.ts`,移除两处租用期间拒绝关闭的旧断言。 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 37cc58bc..4c9e7692 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -5,22 +5,34 @@ | Runtime | 权限确认 | 业务问答 | | --- | --- | --- | | 本机 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`;跳过发送拒绝回答的文本 | | DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 | -托管 SSH OpenCode 的 ACP 权限处理与直接启动配置由 -[远程主机技术设计](../remote-host/technical-design.md)定义;本机 SDK 问答适配不代表远程 -ACP 已提供相同业务问答接口。 +托管 SSH OpenCode 的权限、原生问题事件与回复链路由 +[远程主机技术设计](../remote-host/technical-design.md)定义。远端业务问答需要包含此适配的 +Agent 和新启动的托管 Runtime;任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。 ## 运行与失败 - 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。 -- 前台问题停留在现有问答卡片,保留回答、提交失败提示与跳过入口。 +- 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的 + `pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复 + 事件不替换当前表单或清空输入,回答或跳过成功后只移除对应 ID,再显示下一张表单。 + 提交失败保留队列、输入和重试入口,队列未清空时继续显示“等待你的回答”。 +- 本机与托管 SSH OpenCode 在提问到达前已收到原生 Task 的 `metadata.sessionId` 时,将精确匹配的 + 子会话关联为可选 `childTaskId`。问答卡片显示该任务名称;对应 Task 卡片显示 + “等待你的回答”,展开后可点击“前往问答表单”滚动并聚焦当前队首表单,仍按顺序回答。 + 没有已知关联的子孙会话问题照常排队,不猜测子任务归属,也不从工具名或进度文本构造表单。 - 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、 自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。 提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。 - 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时 不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。 +- 待答队列仅保存在当前桌面会话内存中,不写入会话快照。刷新仍在运行的消息时保留整个 + 本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存 + 已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH + 重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。 - 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。 - 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。 - 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应 diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index 34e2026b..2d1edd98 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -86,6 +86,40 @@ type BuiltinModelToolSummary = { - 输入和目标文件不设固定总量上限。单次工具结果仍遵守直连模型上下文边界;读取返回续读 位置,搜索截断时要求模型缩小路径、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 打包 固定依赖 `@vscode/ripgrep`,构建钩子根据 Electron 目标选择对应 Windows、macOS 或 Linux diff --git a/docs/features/knowledge-base/README.md b/docs/features/knowledge-base/README.md index 6919bec6..3e82a784 100644 --- a/docs/features/knowledge-base/README.md +++ b/docs/features/knowledge-base/README.md @@ -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) | 检索、分块、诊断和产品验收 | | [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 | -| [UI 设计](./ui-design.md) | 当前本地知识库的信息架构、首次使用、状态与高级功能分层 | +| [UI 设计](./ui-design.md) | 本地知识库的信息架构、首次使用、状态与外部界面分工 | | [实施进度](./progress.md) | 已验证实现事实、验证命令与剩余外部知识库工作 | | [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 | | [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 | @@ -18,10 +22,12 @@ | [外部知识库 User Stories](./external-knowledge-user-stories.md) | 实例配置、添加、使用和故障恢复场景 | | [外部知识库逻辑设计](./external-knowledge-logic-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) | 脱敏记录真实实例的接口状态、字段结构、特色能力和耗时 | | [2026-09-10 基础复测](./external-knowledge-probe-2026-09-10-strict.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 和实现契约 -共同约束。外部知识库继续使用独立逻辑设计,直至进入实施阶段。 +定义。外部知识库的实例、绑定和失败规则由独立逻辑设计维护。 diff --git a/docs/features/knowledge-base/external-knowledge-follow-up-2026-09-13.json b/docs/features/knowledge-base/external-knowledge-follow-up-2026-09-13.json new file mode 100644 index 00000000..43b29e8a --- /dev/null +++ b/docs/features/knowledge-base/external-knowledge-follow-up-2026-09-13.json @@ -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" + } +} diff --git a/docs/features/knowledge-base/external-knowledge-logic-design.md b/docs/features/knowledge-base/external-knowledge-logic-design.md index 7b493465..b70b4c5c 100644 --- a/docs/features/knowledge-base/external-knowledge-logic-design.md +++ b/docs/features/knowledge-base/external-knowledge-logic-design.md @@ -4,345 +4,167 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 设计稿 | -| 版本 | 0.1 | -| 日期 | 2026-09-03 | +| 状态 | 当前实现规则;三家短答案、Dify 完整 App 与当前 UI 自动回归已通过,远程待验收 | +| 版本 | 0.2 | +| 日期 | 2026-09-13 | | 关联需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | | 关联场景 | [外部知识库 User Stories](./external-knowledge-user-stories.md) | +| 技术契约 | [技术设计](./external-knowledge-technical-design.md) | ## 1. 不变量 -### EK-I-01 单一知识库身份 - -本地知识库和外部知识库都使用 GoodBuddy `knowledgeBaseId` 参与聊天范围、检索模式、 -工具授权和活动记录。Provider、实例 ID 和远端知识库 ID 不能替代该授权单位。 - -对应:`EK-FR-10`、`EK-FR-11`、`EK-US-C1`。 - -### EK-I-02 外部绑定只读 - -GoodBuddy 对外部实例只执行连接测试、目录读取、详情读取和检索。添加、编辑、移除 -均只修改本机记录,不调用远端创建、更新、上传或删除接口。 - -对应:`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`。 +| 稳定 ID | 规则 | 对应需求 | +| --- | --- | --- | +| `EK-I-01` 单一知识库身份 | 本地与外部都以 GoodBuddy `knowledgeBaseId` 参与聊天范围与工具授权,实例和远端 ID 不扩大选中范围 | `EK-FR-10`、`EK-FR-11` | +| `EK-I-02` 外部绑定只读 | 添加、配置和移除仅修改本机记录;请求限于目录、详情、检索 | `EK-FR-15` | +| `EK-I-03` 凭据边界 | 新密钥在输入表单和保存 IPC 中短暂存在;加密后只在 Main 解密使用,摘要与引用不返回密钥 | `EK-FR-02`、`EK-FR-11` | +| `EK-I-04` 本地定义界面 | Provider 只返回数据,控件和配置由本地共享契约及组件定义 | `EK-FR-09` | +| `EK-I-05` 无静默切换 | 请求使用保存的实例、目标和配置;失败不更换实例、公共云地址或其他知识库 | `EK-FR-14`、`EK-FR-16` | +| `EK-I-06` 零结果与失败分离 | 合法成功响应无片段是零命中;请求或响应失败保留失败信息 | `EK-FR-14` | +| `EK-I-07` 外部证据不可信 | 标准化片段按已有知识证据边界进入 Runtime,不改变工具权限 | `EK-FR-12` | +| `EK-I-08` 配置不跨类型复用 | Provider 配置必须与实例一致,类型切换不迁移同名字段 | `EK-FR-06`、`EK-FR-09` | ## 2. 对象关系 -```text -ExternalKnowledgeInstance 1 ─── n ExternalKnowledgeBinding - │ - │ 1:1 - ▼ - KnowledgeBase -``` +一个实例可以有零个或多个绑定;一个绑定对应一个 GoodBuddy 知识库和一个远端目标。 +同一实例与远端 ID 的组合唯一,不同实例可以使用相同 ID。本地知识库没有外部绑定。 +类型由绑定关系推导,旧本地知识库不需要改成另一套对象。 -- 实例可以没有绑定。 -- 外部知识库只能绑定一个实例和一个远端知识库 ID。 -- 同一实例的同一远端知识库默认只允许存在一个绑定,避免重复选择和重复检索。 -- 允许不同实例绑定同名或相同远端 ID,因为实例边界不同。 -- 本地知识库不存在 `ExternalKnowledgeBinding`。 +GoodBuddy 显示名称与 `remoteName` 分开保存。刷新详情仅更新当前视图,不自动改写本地 +名称或持久化远端名称;重新保存绑定时才写入当前表单使用的远端名称。 -## 3. 实例状态 +## 3. 实例状态与连接测试 -实例状态由启停、凭据和最近探测三个维度组成,不能用一个布尔值代替。 - -### 3.1 持久状态 - -| 维度 | 值 | +| 状态维度 | 当前值与行为 | | --- | --- | -| 启停 | `enabled`、`disabled` | -| 凭据 | `configured`、`missing`、`unavailable` | -| 探测 | `untested`、`catalog-ready`、`healthy`、`list-restricted`、`auth-failed`、`unreachable`、`incompatible`、`failed` | +| 启停 | `enabled` 为 false 时 Main 拒绝目录和检索,并取消当前实例请求 | +| 凭据 | `configured`、`missing`、`unavailable`;列表尝试解密判断可用性 | +| 最近探测 | `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 -idle - └─ test → connecting - ├─ 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 -``` +目录加载、空结果、搜索无结果和失败分别展示;失败提供重试,用户可主动切换手工 ID。 +已经绑定的目录项禁用。手工 ID 不依赖详情接口一定可用,但保存前仍必须通过实际检索。 +详情失败不阻断基础检索,例如旧 Dify 的详情 405。 -检索探测需要一个目标知识库。新增实例尚未选择目标时,目录成功即可把实例保存为“目录可用, -检索待绑定验证”;创建绑定时补做真实检索。手工模式必须先填写目标 ID,再执行检索探测。 -检索请求正常完成但返回零条结果时,说明接口和目标可访问,可继续创建绑定;失败状态按错误 -分类处理。 +Renderer 丢弃已失效的目录、详情和测试响应,不把切换前的结果写回当前表单。它没有 +通过 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. 配置与优先级 -| 字段 | 范围 | 默认值 | 语义 | -| --- | --- | --- | --- | -| `resultLimit` | 1 至 20 | 6 | Provider 结果标准化后最多保留的片段数 | -| `requestTimeoutMs` | 1,000 至 60,000 | 15,000 | 单次远端请求总时限 | -| `maxSnippetCharacters` | 500 至 8,000 | 4,000 | 单片段进入 IPC 和 Runtime 前的字符上限 | +当前配置的精确字段、数值范围和默认值由[技术设计第 3 节](./external-knowledge-technical-design.md#3-共享契约) +对应的共享 Schema 定义。恢复默认只重置本地表单,保存成功后才影响该绑定后续请求。 -Provider 请求仍使用自己的候选数量或 Token 预算。`resultLimit` 只定义 GoodBuddy 最终保留 -上限,不改写 Provider 字段的单位。 +Dify `useDatasetDefaults=true` 时省略整个覆盖对象,使用远端当时配置;远端管理员修改 +默认值会影响后续结果。关闭后按完整覆盖对象发送,不与远端默认逐字段合并。 +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` 覆盖 | -| `searchMethod` | keyword / semantic / full-text / hybrid | 对应 Dataset Retrieve 检索方式 | -| `providerTopK` | 1 至适配器上限 | Dify 返回候选数量 | -| `scoreThresholdEnabled` | boolean | 是否向 Dify 发送分数阈值 | -| `scoreThreshold` | 0 至 1 | 开启阈值后生效 | -| `rerankingMode` | weighted-score / reranking-model | 覆盖检索配置时必填 | -| `rerankingProvider`、`rerankingModel` | 受支持的远端标识 | 选择模型重排时必填 | -| `keywordWeight`、`vectorWeight` | 0 至 1,总和为 1 | 加权混合模式时生效 | -| `embeddingProvider`、`embeddingModel` | 受支持的远端标识 | Dify 要求时从知识库详情选择或填写 | +| `EK-FR-01` 至 `EK-FR-05` | 实例状态、保存后测试、目录与手工 ID | `EK-US-A1`、`EK-US-A2`、`EK-US-B1`、`EK-US-B2` | +| `EK-FR-06` 至 `EK-FR-10` | 对象关系、创建编辑、配置与优先级 | `EK-US-B3`、`EK-US-B4`、`EK-US-D1`、`EK-US-D3` | +| `EK-FR-11` 至 `EK-FR-14` | 范围、可用性、结果与失败、引用 | `EK-US-C1` 至 `EK-US-C4`、`EK-US-D2`、`EK-US-D4` | +| `EK-FR-15`、`EK-FR-16` | 只读生命周期、凭据与请求边界 | `EK-US-A3`、`EK-US-C2` | -`useDatasetDefaults` 开启时,其余覆盖字段隐藏且不发送。关闭后必须收集当前 Dify 接口 -构造 `retrieval_model` 所需的完整字段,缺少 Provider 或模型标识时不能保存。 - -### 7.3 FastGPT 配置 - -| 字段 | 初始范围 | 说明 | -| --- | --- | --- | -| `searchMode` | embedding / fullTextRecall / mixedRecall | 搜索方式 | -| `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。 -- 依据真实延迟确定默认并发上限和跨知识库上下文配额。 +三家真实 Provider 的生产服务、本机 HTTP MCP、生产 IPC `always` 短答案,以及 Dify +`auto` 与完整 App 输入区回答、引用已有证据,详见[实施进度](./progress.md)。`auto` 本轮 +使用进程内工具调用,不覆盖远程 ACP。FastGPT/RAGFlow 完整桌面、复杂问答和远程 +forced-preflight 仍待完成;远程已确认网络可达,但已有 SSH 凭据在验证程序内无法解密。 +后续 Portal 与控件修正后的聚焦测试、全量回归及相关检查已通过,精确结果由实施进度维护。 +原场景中的保存前分项测试、按地址 +传输确认、取消交互、版本能力状态和完整诊断尚不等同当前实现,不能把对应 User Story +全部标为通过。是否调整这些验收要求由功能 owner 确认,本轮不增加新的状态框架。 diff --git a/docs/features/knowledge-base/external-knowledge-prd.md b/docs/features/knowledge-base/external-knowledge-prd.md index 072079f6..b0c0de9c 100644 --- a/docs/features/knowledge-base/external-knowledge-prd.md +++ b/docs/features/knowledge-base/external-knowledge-prd.md @@ -4,14 +4,38 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 设计稿 | -| 版本 | 0.1 | -| 日期 | 2026-09-03 | +| 状态 | 三家短答案、Dify 完整 App 与当前 UI 自动回归已通过;剩余产品验收待完成 | +| 版本 | 0.2 | +| 日期 | 2026-09-13 | | 适用产品 | GoodBuddy 桌面端 | | 关联逻辑 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | | 关联界面 | [外部知识库 UI 设计](./external-knowledge-ui-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. 背景 不少企业已经在内网部署 Dify、FastGPT 或 RAGFlow,并在其中维护制度、产品、研发 @@ -42,6 +66,11 @@ GoodBuddy 负责。 10. API Key 在用户输入表单和受限 IPC 中短暂存在,由 Main 进程加密保存并用于请求; 保存后不回传 Renderer,也不进入 Runtime、日志、普通元数据和检索引用。 +第一版按只读接入交付:参数修改和恢复默认只更新 GoodBuddy 中当前绑定的客户端配置, +在后续检索请求中使用,不修改远端知识库默认配置,也不影响其他客户端。远端文档、 +索引、图谱构建、模型和 Pipeline 设置由用户在对应平台维护。检索请求仍可能产生远端 +Embedding、重排用量和审计记录。 + ## 3. 目标 ### 3.1 用户目标 @@ -93,22 +122,23 @@ GoodBuddy 负责。 1. 用户在知识库页面点击“管理外部实例”。 2. 页面列出实例名称、Provider、服务地址、状态和已绑定知识库数量。 3. 用户添加实例,填写名称、Provider、服务地址和 API Key。 -4. “测试连接”分别验证服务可达、认证、知识库列表和检索能力。 -5. 用户保存后,API Key 被加密保存,列表只显示凭据已配置。 +4. 用户先保存实例,API Key 被加密保存,列表显示凭据状态和“未测试”。 +5. 在已保存实例上“测试连接”读取目录并记录结果;检索能力在绑定目标时验证。 -列表接口不可用但已知远端知识库 ID 可检索时,实例可以保存为“需手工指定知识库”。 -服务不可达或认证失败时不得保存为可用实例;用户可以保留表单并修正。 +目录 403 显示“需手工指定知识库”;不可达或认证失败保留实例和失败状态,用户可编辑 +后重试。保存本身不代表可用。原分项测试验收目标见 `EK-FR-03`,当前实现差异见上表。 ### 6.2 添加外部知识库 1. 用户点击现有“新建知识库”。 2. 在“知识库类型”中选择“本地”、Dify、FastGPT 或 RAGFlow。 3. 选择外部类型后,左侧选择同 Provider 的外部实例。 -4. GoodBuddy 自动读取远端知识库列表,并填入可搜索下拉框。 +4. GoodBuddy 自动读取远端知识库列表,提供搜索、分页和目标选择。 5. 列表权限不可用时,用户可以手工填写远端知识库 ID。 6. 用户选择一个远端知识库,填写 GoodBuddy 显示名称。 7. 右侧展示通用配置和当前 Provider 的专属配置。 -8. 用户填写测试查询并执行一次有界检索;请求成功后创建本地绑定,零结果不算失败。 +8. 用户填写测试查询并执行有界检索;成功后可添加,零结果不算失败。保存时服务会再次 + 执行该查询验证目标和配置,之后创建本地绑定。 添加完成后沿用现有知识库规则,把新绑定加入当前聊天选择,但不会重新启用用户此前 取消的其他知识库。 @@ -222,13 +252,20 @@ Provider 的远端创建、更新、上传或删除接口。 | --- | --- | --- | --- | | Dify | 知识库列表与详情 | Dataset Retrieve | 使用知识库默认配置,或完整覆盖检索方式、Top K、Score 阈值及 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)共同决定。 ## 9. 产品验收 +以下为验收清单,尚未整体通过。第 2 项已有三家生产服务绑定与检索证据,Dify 另有桌面 +操作证据;其他 Provider 的桌面、受限目录与高级配置等仍需补验。第 12 项确认流程当前 +未实现。早期历史引用检查使用标记为测试的会话;后续已从完整 App 输入区生成 Dify +真实短答案并点击引用,三家另有生产 IPC 短答案证据。完整 App 尚未覆盖 FastGPT/RAGFlow, +也未评估广泛问答质量;本机 MCP 成功不替代被 SSH 凭据解密阻断的真实远程预检索。 +详细结果以实施进度为准。 + 1. 用户可以在知识库页面完成三种 Provider 的实例添加、连接测试、编辑和删除。 2. 每种 Provider 至少通过一个真实自部署实例完成列表读取、绑定和检索。 3. 列表权限关闭时,可以通过手工 ID 添加并验证一个可检索知识库。 diff --git a/docs/features/knowledge-base/external-knowledge-technical-design.md b/docs/features/knowledge-base/external-knowledge-technical-design.md index 010c6c89..8e4a5f8c 100644 --- a/docs/features/knowledge-base/external-knowledge-technical-design.md +++ b/docs/features/knowledge-base/external-knowledge-technical-design.md @@ -4,721 +4,309 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 设计稿 | -| 版本 | 0.1 | -| 日期 | 2026-09-03 | +| 状态 | 三家生产 IPC 真实生成、Dify 完整 App 与当前 UI 自动回归已通过;远程待验收 | +| 版本 | 0.2 | +| 日期 | 2026-09-13 | | 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | -| 逻辑设计 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | -| UI 设计 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) | +| 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) | +| 界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) | +| 验证状态 | [实施进度](./progress.md) | -## 1. 设计约束 +## 1. 实现边界 -1. 外部知识库扩展现有知识库领域,不建立独立检索工具、聊天范围或 Runtime 权限模型。 -2. 外部绑定使用远端检索,不落地远端文档、分块、向量和图谱。 -3. 所有网络、凭据、Provider 响应校验和结果标准化都在 Main 完成。 -4. Renderer 只能通过受限 IPC 管理实例、读取目录、保存绑定和发起测试。 -5. Dify、FastGPT、RAGFlow 使用独立 Adapter 和判别配置,不通过任意 JSON 请求模板实现。 -6. 首次实现只调用无内容修改语义的列表、详情和检索接口。 -7. Provider API 变化通过真实 fixture、Adapter 版本和能力快照处理,不通过猜测字段兼容。 +外部知识库复用 GoodBuddy 知识库 ID、聊天范围和检索入口。网络、凭据、响应读取和 +标准化在 Main 完成,Renderer 通过明确的 Preload 方法调用 IPC。远端只执行目录、详情和 +检索请求,不创建、修改、上传或删除远端内容,不调用外部 App、Chat、Workflow 或 Agent。 -## 2. 现有代码边界 +本地 `file | directory | url` 仍表示需要导入和索引的资料来源。外部绑定独立存储, +不加入该枚举,也不生成本地文档、分块、向量或图谱。实际用于回答的片段随会话引用保存。 -当前实现中的主要扩展点: +## 2. 当前模块 -| 边界 | 现有位置 | 需要的变化 | -| --- | --- | --- | -| 共享知识契约 | `src/shared/knowledge-contracts.ts`、`src/shared/contracts.ts` | 增加实例、绑定、Provider 配置和外部引用判别联合 | -| IPC 名称 | `src/shared/ipc-channels.ts` | 增加实例和目录操作通道 | -| Preload | `src/preload/index.ts` | 暴露受限、类型化的外部知识库 API | -| Main IPC | `src/main/ipc.ts` | 校验输入、调用服务、投影脱敏结果 | -| 知识服务 | `src/main/knowledge/knowledge-service.ts` | 按知识库来源分派本地或外部检索 | -| 数据库 | `src/main/knowledge/knowledge-database.ts` | 增加实例、绑定和迁移 | -| 内部类型 | `src/main/knowledge/types.ts` | 增加 Provider、实例、绑定和标准化结果类型 | -| 知识页面 | `src/renderer/src/KnowledgeWorkspace.tsx` | 实例管理、创建向导类型和外部详情 | -| 检索测试 | `src/renderer/src/KnowledgeRetrievalWorkbench.tsx` | 支持本地与外部诊断判别联合 | -| 聊天预检索 | `src/main/ipc.ts` | 统一调用 Provider-aware 检索编排 | -| Agent 知识工具 | `src/main/agent/knowledge-mcp-gateway.ts` | 与预检索复用同一编排和引用标准化 | +| 文件 | 职责 | +| --- | --- | +| `src/main/knowledge/external/external-knowledge-client.ts` | 一个 `ExternalKnowledgeClient` 内按 Provider 分支处理固定端点、地址规范化、分页、HTTP 错误、超时、取消、响应上限和结果映射;同文件定义 `ExternalKnowledgeError` | +| `src/main/knowledge/external/external-knowledge-service.ts` | 实例保存与测试、凭据加解密、目录与详情、检索测试、创建或更新绑定、请求生命周期与配置变化检查 | +| `src/main/knowledge/external/external-knowledge-store.ts` | 在已有 SQLite 连接上读写实例 JSON 和绑定 JSON | +| `src/main/knowledge/knowledge-database.ts` | v12 迁移、知识库与绑定事务、唯一约束及删除外键 | +| `src/main/knowledge/knowledge-service.ts` | 本地与外部检索分派、混合检索和总字符预算;阻止外部绑定进入本地维护操作 | +| `src/shared/external-knowledge-contracts.ts` | Provider 配置、实例与绑定输入、目录、测试结果和外部引用定位契约 | +| `src/shared/knowledge-contracts.ts`、`contracts.ts`、`assistant-contracts.ts` | 现有知识结果、列表和会话引用增加外部字段 | +| `src/shared/knowledge-reference.ts` | 预检索与 MCP 共用的引用转换和去重键 | +| `src/main/ipc.ts`、`src/preload/index.ts`、`src/shared/ipc-channels.ts` | 可信发送方检查、Zod 输入解析、类型化跨进程调用 | +| `src/renderer/src/ExternalKnowledge.tsx` | 实例 Modal、绑定表单、Provider 配置、外部详情与检索结果 | +| `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 -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 -``` +### 3.1 实例与绑定 -职责: +实例输入包含 `id?`、`name`、`provider`、`baseUrl`、`enabled` 和 +`credential: keep | replace | clear`。实例摘要返回启停、凭据状态、`probeStatus`、绑定数、 +最近测试时间和错误码,不返回凭据 envelope。当前没有 `detectedVersion`、`adapterVersion` +或 `transportSecurity` 字段。 -- `ExternalKnowledgeService`:实例、目录、绑定、测试和检索编排。 -- `ExternalKnowledgeStore`:实例和绑定持久化,凭据加解密。 -- `ExternalKnowledgeHttp`:超时、取消、大小限制、地址和响应读取。 -- `ExternalKnowledgeRegistry`:按 Provider 返回内置 Adapter,不接受运行时插件注册。 -- `ExternalKnowledgeNormalizer`:统一结果边界、截断和引用。 -- Adapter:构造固定端点与请求,解析 Provider 响应,声明静态配置和能力条件。 +绑定保存 `knowledgeBaseId`、`instanceId`、`provider`、`remoteKnowledgeBaseId`、 +`remoteName`、`commonConfig`、`providerConfig` 和 `lastVerifiedAt`。GoodBuddy 名称和描述 +保留在知识库记录。创建和更新输入都要求用户填写 `testQuery`;服务保存前重新执行检索, +因此先点击“测试检索”再保存会产生两次检索请求。成功零命中允许保存。 -`KnowledgeService` 继续作为聊天、检索测试和 MCP 网关的统一入口。它根据 -`KnowledgeBase.kind` 调用现有本地检索或 `ExternalKnowledgeService.retrieve`。 +### 3.2 配置范围 -## 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 -type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow' +Provider 必须与实例一致。Dify 加权配置还在客户端检查权重和为 1。共享查询输入上限为 +4,000 字符,Dify 客户端另按 Unicode 码点拒绝超过 250 字符的查询,不静默压缩。 -interface ExternalKnowledgeAdapter { - readonly provider: ExternalKnowledgeProvider - readonly configVersion: number +### 3.3 结果与引用 - normalizeBaseUrl(input: string): URL - testConnection(context: AdapterContext): Promise - listKnowledgeBases( - context: AdapterContext, - input: CatalogPageInput - ): Promise - getKnowledgeBase( - context: AdapterContext, - remoteKnowledgeBaseId: string - ): Promise - probeRetrieval( - context: AdapterContext, - input: ProviderRetrieveInput - ): Promise - retrieve( - context: AdapterContext, - input: ProviderRetrieveInput - ): Promise - parseCapabilities(input: unknown): CapabilitySnapshot - validateConfig( - input: unknown, - capabilities: CapabilitySnapshot - ): PersistedConfig -} -``` +现有 `KnowledgeRetrievalResult` 的本地 `documentId/sourceId/chunkId` 改为可选,增加 +`external?: ExternalKnowledgeLocator`,没有另建一套 `UnifiedKnowledgeRetrievalResult`。 +外部定位包含 Provider、实例 ID、远端库 ID,以及实际返回的文档 ID、片段 ID、位置和评分。 +FastGPT 数组评分存为 `providerScores: {type, value, index?}[]`,保留顺序与类型;数值评分 +存为 `providerScore`,不选取数组首项冒充总分。 -`probeRetrieval` 可以复用 `retrieve`,但必须使用用户在创建向导中明确输入的测试查询。 -不得生成会泄露组织信息的默认问题,不得调用 Provider 的 App 或模型问答接口,也不得 -修改远端知识内容。Provider 自身记录 API 审计或计算检索用量不属于内容修改,必须由界面 -提前说明。 +外部结果沿用旧结果容器的空 `channels` 和 `scores.fusedScore = 0`。该零值是结构占位, +不表示质量;`toKnowledgeReference` 不把它写成引用总分。外部结果没有统一 `relevance`, +不同 Provider 的原始分数不参与跨库混排。引用去重键包含外部定位和片段,不伪造本地 ID。 -Adapter 返回数据对象,不返回 React 组件、HTML 或远端 Schema。Renderer 中的配置定义 -由共享的 Provider 判别联合和本地字段描述生成。 +## 4. SQLite 与凭据 -## 5. 共享契约 - -### 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 - } - -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')); -``` - -增加实例表: +数据库 v12 增加两张表: ```sql CREATE TABLE external_knowledge_instances ( id TEXT PRIMARY KEY, - name 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 + value_json TEXT NOT NULL ); -``` - -增加绑定表: - -```sql CREATE TABLE 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_knowledge_base_id TEXT NOT NULL, - 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) + 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) ); ``` -已有知识库迁移为 `kind = 'local'`。外部知识库仍保留现有名称、描述和创建时间,但本地 -`storageMode`、图谱、分块和本地检索设置不进入外部请求;后续可以在数据库重构时再移除 -这些历史必填默认值,本功能不为此扩大迁移范围。 +没有向 `knowledge_bases` 增加 `kind` 列;IPC 快照根据是否存在绑定投影 +`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。 -- `keep`:不读取、不重写现有 envelope。 -- `clear`:确认后删除 envelope,并把实例凭据状态设为缺失。 -- 安全存储不可用时拒绝新增或替换,不降级为明文。 -- 解密只发生在发起请求前;请求结束后不缓存明文。 +Preload 暴露实例 `List/Save/Test/SetEnabled/Delete`、目录 `List/Get`、绑定 +`Create/Update` 和 `externalRetrievalTest`。通道使用 `knowledge:external-*` 前缀, +准确名称以 `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 -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 -``` +## 6. HTTP 边界 -所有 Handler 必须: +客户端只接受 HTTP(S),拒绝地址内的用户名、密码、查询与片段;按 Provider 规范化 API +路径,仅拼接内置端点。请求使用 Bearer 认证,`redirect: 'error'`,无自动重试或公共云回退。 +默认超时 15 秒,单个响应默认上限 5,000,000 字节;响应头和流式读取均检查上限,取消 +期间终止读取。解析后只投影需要的有界字段,最多返回 20 条、每片段 8,000 字符。 -1. `assertTrustedSender`。 -2. 用共享 Zod Schema 解析严格输入。 -3. 根据实例 ID 在 Main 读取地址和凭据,不接受 Renderer 在检索请求中重复传入。 -4. 使用稳定错误码返回脱敏错误。 -5. 对网络操作支持 AbortSignal、总超时和响应大小限制。 +当前使用 `fetcher ?? fetch`,没有单独连接全局内网兼容模式、证书例外或按地址保存传输 +确认。2026-09-13 当前生产服务在真实 Electron 中对两个 Dify 和 FastGPT HTTPS 配置完成 +严格证书校验,RAGFlow 使用 HTTP。此证据覆盖这些配置的请求,不表示旧 PRD 的地址确认 +或证书例外流程已实现;相关产品差异仍由功能 owner 核对。 -目录和测试请求返回 `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:`。 -- 拒绝用户名、密码、查询和片段。 -- Adapter 负责规范化 Provider 所需 API 根路径,避免用户填写完整操作端点。 -- 请求只能拼接 Adapter 内置相对路径,远端目录项或错误不能改变目标 URL。 -- 重定向默认拒绝;若某 Provider 的受支持部署确需重定向,只允许同源且不得跨跳转发送 - Authorization。 -- 当前全局“内网兼容模式”决定是否允许 HTTP 和非标准 HTTPS 证书;操作开始后冻结该值。 -- HTTP 或实际使用证书例外时,Main 返回 `transportSecurity` 风险状态。用户必须针对规范化 - 后的当前地址明确确认,才能保存实例或发送 API Key;地址变化立即使旧确认失效。 -- 不进行公共云回退,不把自部署地址替换成 Provider 官方地址。 +服务测试先按绑定的结果数与单片段限制裁剪,并限制该次片段总量为 48,000 字符。 +`retrieveMany` 展平各库结果并按库内 rank 稳定排序,再轮转分配 48,000 字符的片段预算: +先处理各库第 1 条,再处理各库第 2 条,同 rank 保持输入库顺序。不会先耗完整个库再轮到 +下一个库;仍没有固定的每库最低字符配额。关联的本地上下文 groups 随相应结果使用另一份 +48,000 字符预算。二次裁剪会标记 `context.truncated`,但第一次标准化裁剪不提供完整的 +远端原始条数与截断诊断。 -用户明确配置内网地址属于产品用途,因此不能采用禁止私网地址的通用 SSRF 规则。安全边界 -依靠显式实例配置、Main-only 请求、固定端点、无任意方法和无远端 URL 跟随。 +聊天预检索、检索测试相关 IPC 和 `KnowledgeMcpGateway` 已使用统一检索入口。引用转换 +复用 `knowledge-reference.ts`,聊天与 MCP 仍按现有授权范围和各自输出预算处理结果。 +MCP 在总预算裁剪后按库内 rank 交错选择结果,再应用工具条数和字节上限;有失败且 +没有任何片段时返回工具错误。预检索只在全部库失败时报告整体失败,成功零命中伴随其他 +库失败时保留降级诊断。这两种上层呈现并不完全相同。 -### 8.2 请求限制 +## 8. 引用显示与历史数据 -- 默认超时 15 秒,用户可在 1 至 60 秒内调整。 -- 目录、详情和检索分别设置响应字节上限。 -- JSON 解析后仍执行深度、数组数量、字符串长度和未知字段策略。 -- 默认不自动重试。用户重试或上层下一次检索会产生新请求。 -- 取消、应用关闭和实例删除立即终止相关请求。 -- 日志只记录 Provider、实例 ID、操作、状态码、稳定错误码、耗时和字节数。 +会话契约和数据库保存外部定位及有界片段。外部引用弹窗读取保存的片段,显示 Provider、 +实例 ID、远端库和实际返回的定位及评分;不请求本地 `referenceContext`,不显示本地 +`openSource` 操作。当前没有远端来源打开或引用刷新请求,`sourceUrl` 是可选契约字段, +三家现有映射没有填充它。 + +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 映射 -本节依据 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 真实实例验证基线 -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.x` | 响应头 `x-version: 0.15.8` | 列表返回 200 和 3 项;详情 GET 返回 405 | 默认配置和显式当前配置均返回 200,结果为空 | 从目录项读取配置,手工 ID 通过检索验证;非空结果仍未验证 | -| Dify `1.x` | 响应头 `x-version: 1.17.0` | 列表返回 200 和 1 项;详情 GET 返回 200 | 默认配置和显式当前配置均返回相同的 3 条非空结果 | 详情和 `records[].segment/document` 已验证;新增摘要索引、元数据、多模态和 Pipeline 能力字段 | -| 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 配置证据 | +历史结果包含 Dify 0.15.8 详情 405、检索零命中,Dify 1.17.0 详情成功及 3 条非空结果; +FastGPT 三种模式和重排非空;RAGFlow 图谱开关产生不同结果,但 Knowledge Compilation +没有配置证据。FastGPT/RAGFlow 产品版本、Dify 0.15.8 非空结构及真实错误矩阵仍未确认。 +逐项数量、脚本命令和当时测试结果由[实施进度](./progress.md#验证证据)维护。 -2026-09-09 的 Node 测试进程设置了 `NODE_TLS_REJECT_UNAUTHORIZED=0`,因此当时 HTTPS 实例的请求 -使用了放宽的证书校验。这只能证明接口在当前测试环境可用,不能证明严格 TLS 校验通过。 -产品实现不得读取该进程级设置作为隐式默认;实例测试和保存必须按 8.1 节处理传输风险。 - -2026-09-10 复测显式设置 `NODE_TLS_REJECT_UNAUTHORIZED=1`,两个 Dify 实例和 FastGPT -均通过证书校验;RAGFlow 配置为 HTTP,不涉及 TLS。基础 14 次、扩展 19 次,共 33 次只读 -请求。每个实例从目录选择一个库检索,未遍历全部库;未调用聊天或生成端点,检索仍可能 -使用远端 Embedding、Rerank 并记录用量。两份报告与逐项结果见[实施进度](./progress.md)。 +2026-09-13 新增当前产品证据:[生产验证摘要](./external-knowledge-validation-2026-09-13.json) +记录真实 Electron safeStorage 持久化重开、生产服务与本机 HTTP MCP,以及 Dify 桌面。 +服务结果与 MCP 引用数分别为 Dify 配置 1 的 0/0、Dify 配置 2 的 2/2、FastGPT 的 2/2、 +RAGFlow 的 1/1。报告配置编号不是版本识别,历史版本差异不据此重新认证。 ### 9.1 Dify -| 操作 | 接口 | -| --- | --- | -| 列表 | `GET /v1/datasets` | -| 详情 | `GET /v1/datasets/{dataset_id}`,`0.15.8` 不支持、`1.17.0` 支持 | -| 检索 | `POST /v1/datasets/{dataset_id}/retrieve` | -| 认证 | `Authorization: Bearer ` | +固定使用 `GET /v1/datasets`、`GET /v1/datasets/{id}` 和 +`POST /v1/datasets/{id}/retrieve`。默认省略整个 `retrieval_model`,远端优先使用知识库 +当时配置,未保存时由远端选默认;覆盖时发送完整的本地 `retrievalModel`,不逐字段合并。 +结果从 `records[].segment`、文档和分数取值。 -Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或 Workflow。默认不 -发送 `retrieval_model`,沿用远端知识库配置。用户选择覆盖时,Adapter 必须一次收集并验证 -当前接口要求的 `search_method`、`reranking_enable`、`top_k`、`score_threshold_enabled`。 -开启重排时再验证 `reranking_mode`,以及该模式所需的 Provider、模型或权重;缺少必填 -标识时不构造请求。显式对象不会与知识库配置逐字段合并。省略整个对象时,服务端优先使用 -知识库配置,未保存配置时才使用内置默认。响应从 -`records[].segment`、`score`、文档名称和元数据提取标准化引用。 +客户端把详情 405 分类为不兼容,绑定表单允许详情失败后继续检索验证,不实现版本探测或 +自动遍历目录的详情回退。Scoped Key 可尝试手工 ID,但当前产品的真实受限密钥验收仍待完成。 +Metadata、多模态、摘要索引、子块、附件和 Pipeline 没有进入当前简化目录或结果映射。 -当前官方文档说明限定知识库范围的 Key 不能调用列表,但可访问授权 ID 的详情和检索。 -列表 403 不足以认定密钥无效;本轮测试配置均能列目录,尚未验证 Scoped Key。 - -Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天 -诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。 - -两个实测版本共同返回 `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 -字段,未执行空条件或猜测字段探测;该配置保持禁用,直到所选库详情返回可用字段定义。 +官方来源:[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)。 ### 9.2 FastGPT -| 操作 | 接口 | -| --- | --- | -| 列表 | `POST /api/core/dataset/list` | -| 详情 | `GET /api/core/dataset/detail?id=...` | -| 检索 | `POST /api/core/dataset/searchTest` | -| 认证 | `Authorization: Bearer ` | +固定使用 `POST /api/core/dataset/list`、`GET /api/core/dataset/detail?id=...` 和 +`POST /api/core/dataset/searchTest`。列表 `data[]` 保留 Dataset 与文件夹,按 `parentId` +读取层级,在当前父级响应内搜索和分页;文件夹只用于浏览。 -列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。根列表请求发送 -`parentId: null`;文件夹需按父 ID 继续读取。当前实测列表为 `data[]`,检索为 -`data.list[]`,这是两个不同端点的结构。旧手工文档还展示检索 `data[]`,探测脚本接受 -该旧结构并单独记录 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 和 -非空结果,因此这四种界面组合可以启用。查询扩展会调用模型,仍不在初始界面和探测脚本中 -启用。 +检索接受业务码 200 的 `data.list[]` 与旧手工文档的 `data[]`,不递归猜测嵌套结构。 +`q` 必需,`a` 仅在存在时追加;评分数组保留类型、值和可选 index,数值评分独立处理。 +当前客户端已通过 FastGPT 真实服务与本机 HTTP MCP,均返回 2 条。报告确认结果契约与 +引用映射有效,没有逐项归档评分包装,故旧包装和全部评分变体仍以定向测试证据为限。 -FastGPT 4.15.0 起应优先查阅部署实例的自动生成文档,`/apidoc/devapi` 与 -`/apidoc/systemopenapi` 的认证范围不同,Dev API 中出现不等于允许 API Key 调用。支持矩阵 -必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest` -可能记录 `SEARCH_TEST` 等远端审计并更新 API Key 用量;Embedding 和 Rerank 也可能产生 -费用。GoodBuddy 将这些行为作为检索成本显示,不宣称该接口没有副作用。 +请求映射 `datasetId/text/limit/similarity/searchMode/usingReRank`,不发送查询扩展。 +Embedding、Rerank 和远端 API 审计仍可能产生用量;不调用 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)。 +4.15.0 起应核对部署实例生成文档;历史检查只取得云端 `/apidoc/` 应用壳。 ### 9.3 RAGFlow -| 操作 | 接口 | -| --- | --- | -| 列表/详情 | `GET /api/v1/datasets` | -| 检索 | `POST /api/v1/retrieval` | -| 认证 | `Authorization: Bearer ` | +目录和按 ID 详情均使用 `GET /api/v1/datasets`,检索使用 `POST /api/v1/retrieval`。 +请求发送 `question`、单个 `dataset_ids`、`page: 1`、`page_size: 20`、阈值、向量权重、 +`knn_top_k` 及显式的图谱、知识编译布尔值。HTTP 成功且业务码为 0 才读取 `data.chunks`。 +文档名映射 `document_keyword`,位置保存返回的 `positions`,分数取原始 `similarity`。 -检索映射 `question`、`dataset_ids`、`page_size`、`similarity_threshold`、 -`vector_similarity_weight`、`knn_top_k`、重排、`use_kg` 和 -`include_knowledge_compilation`。当前文档以 `knn_top_k` 取代 `top_k`;v0.24.0 文档只声明 -`top_k`,因此当前客户端不发送 `top_k` 的行为不能作为旧版本兼容承诺。 -`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`。 +`graphEnabled` 根据有效非零 `graphrag_task_finish_at` 判断;仅配置 +`parser_config.graphrag.use_graphrag` 不算完成。知识编译依据非空 +`compilation_template_group_id`。启用任一能力时,服务在检索前重新读取详情确认。 +当前不保存完整能力快照,不区分图谱未配置与构建中,也不检测 Provider 版本。 -图谱规则: +官方来源:[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;单独出现该配置不 - 表示图谱已经构建完成。 -- `graphrag_task_finish_at` 为有效非零时间才作为图谱完成证据,不写回。 -- `use_kg` 是检索时开关,只在已有图谱和受支持版本均确认后发送。 -- Knowledge Compilation 与旧 GraphRAG 分别建模。 -- `include_knowledge_compilation` 使用本地保存的显式布尔值,初始默认 false;即使服务端 - 默认 true,也不依赖字段省略获得隐式行为。 -- `compilation_template_group_id` 或后续版本的等价受控字段作为 Knowledge Compilation 已 - 配置证据;仅仅请求返回 200 不能启用界面开关。 -- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。 +## 10. 验证与验收 -2026-09-09 实例 28 个库中 17 个配置 GraphRAG、15 个存在完成证据。对一个已完成库使用同一查询, -基础检索返回 0 条,`use_kg=true` 返回 1 条且原始相似度为 1,说明图谱开关对实际结果有 -影响。当时 28 个库均没有 Knowledge Compilation 配置证据;该请求虽返回 200 和零结果, -界面仍必须禁用开关并显示“当前知识库未配置 Knowledge Compilation”。 -2026-09-10 目录增至 29 项,18 项配置图谱、16 项有完成证据;所选库基础检索 2 条、 -图谱检索 3 条。Knowledge Compilation 开关请求返回 2 条,目录仍无配置证据,不能据此 -认定编译内容参与了检索。 +三家 Provider 已通过生产 `KnowledgeService` 与本机 HTTP MCP,真实 Electron +safeStorage 加解密和持久化重开成功。Dify 桌面已完成绑定、参数更新、重启、三档窗口宽度 +及历史引用检查。验证方法、原始报告边界和调用计数见[实施进度](./progress.md)及 +[脱敏摘要](./external-knowledge-validation-2026-09-13.json)。本轮文档只读取报告,没有 +另发 Provider 或模型请求,也未改动历史探测 JSON。 -## 10. 检索编排 +当前 Portal 与控件修改后的聚焦测试、全量回归、类型检查、lint、工作区及暂存区 diff +检查均已通过。精确计数、运行命令、耗时、执行期间文件稳定性和原失败修正记录统一见 +[实施进度](./progress.md#2026-09-13-当前工作区实现与分组验证)。 -```text -retrieveMany(knowledgeBaseIds, query, requestContext) - ├─ load and authorize GoodBuddy knowledge bases - ├─ partition local and external bindings - ├─ local: existing retrieval path - ├─ external: bounded concurrent adapter requests - ├─ validate and normalize each response - ├─ allocate deterministic context budget - └─ return evidence plus per-library diagnostics -``` +后续真实生成使用已保存的默认模型,凭据在 Main 通过 safeStorage 解密。三家 Provider +的 `always` 及 Dify `auto` 已产生有依据和引用的短答案;Dify `auto` 经进程内 +`ModelToolProvider → KnowledgeMcpGateway` 执行,不能当成本机 HTTP MCP 或远程 ACP +验收。FastGPT/RAGFlow 首轮因直接比较序列化字符串产生验证断言失败,解码请求和嵌入 +证据 JSON 后重跑通过;报告保留初始结果。完整 App 输入区的发送、真实答案及引用点击 +仅覆盖 Dify。Dify/FastGPT 使用严格 HTTPS,RAGFlow 和模型使用已配置的 HTTP。 -预检索和 `knowledge_search` 工具必须调用同一个 `retrieveMany`。如果只修改聊天预检索而 -未修改 `KnowledgeMcpGateway`,`auto` 和 `always` 会产生不同能力,这是阻断发布的问题。 +请求精确增量与视觉 fixture 边界见[后续验证摘要](./external-knowledge-follow-up-2026-09-13.json) +及[实施进度](./progress.md#2026-09-13-ui-修正与真实生成复验)。不公开模型端点、私有配置 +标识、凭据和答案正文。这些短答案检查没有评估广泛问答质量。 -### 10.1 上下文预算 +FastGPT/RAGFlow 完整桌面专属操作、复杂或混合问答、删除和真实错误矩阵仍需补验。 +Linux Host 的两条网络路径可达,但验证程序无法解密已有 SSH 凭据,在认证前返回 +`E_SAFE_STORAGE_DECRYPT_FAILED`,真实远程 forced-preflight 未执行。本机 HTTP MCP +成功不覆盖该远程路径。剩余 Provider 版本和高级参数效果也不能由基础检索成功推断。 -- 每个知识库先保留不超过其 `resultLimit` 的结果。 -- 先给每个成功知识库一个最小有界配额,再按稳定顺序分配剩余字符。 -- 不用不同 Provider 原始分数决定跨库优先级。 -- 片段截断在 Main 完成并在引用中标记。 -- 相同实例的多个绑定仍按绑定分别请求,除非 Provider 明确支持且 Adapter 实现等价的多 - Dataset 请求;合并不得改变每个绑定的配置和诊断。 +原设计的分项探测 UI、最低版本矩阵、详细诊断、地址确认和取消交互未全部落地。它们是 +待核对的产品差异,不能以补建 Adapter Registry 或版本框架代替验收决策。 -### 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__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= -``` - -默认模式覆盖目录、详情和基础检索。`--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-04`、`EK-FR-05` | Adapter 目录与详情接口、目录 IPC | Provider fixture、手工 ID 集成测试 | -| `EK-FR-06` 至 `EK-FR-09` | Provider 判别联合、Adapter 配置版本、Renderer 本地字段定义 | 契约测试、UI 字段显隐测试 | -| `EK-FR-10`、`EK-FR-11` | `KnowledgeBase.kind`、绑定表、统一 `retrieveMany` | 本地与外部混合检索测试 | -| `EK-FR-12`、`EK-FR-13` | 引用定位判别联合、结果标准化、检索测试 IPC | 引用持久化与 UI 测试 | -| `EK-FR-14` | 稳定错误码、逐库 Outcome、取消和超时 | 错误 fixture、部分失败测试 | -| `EK-FR-15` | 外键限制、绑定本地删除、无远端写接口 | 删除生命周期测试 | -| `EK-FR-16` | Main-only HTTP、固定端点、传输风险确认 | 地址、证书和凭据泄漏测试 | +| `EK-FR-01` 至 `EK-FR-05` | 实例 Service/Store、目录 Client、IPC 与管理表单 | 服务已验证;补充 FastGPT/RAGFlow 桌面和受限目录手工 ID | +| `EK-FR-06` 至 `EK-FR-10` | 共享 Provider 配置、绑定事务、统一列表与两栏表单 | Dify 参数与布局已验证;其他 Provider 专属参数及未提供字段待核对 | +| `EK-FR-11` 至 `EK-FR-14` | `retrieveMany`、预检索、MCP、共享引用与 failure 字段 | 三家 IPC 短答案与 Dify 完整输入区引用已验证;混合失败、广泛问答与远程待补 | +| `EK-FR-15` | 唯一约束、外键保护、顺序本地删除 | 中途失败刷新、远端内容不变 | +| `EK-FR-16` | Main 固定端点、有界请求与数据说明 | 当前桌面传输行为与 PRD 差异 | diff --git a/docs/features/knowledge-base/external-knowledge-ui-design.md b/docs/features/knowledge-base/external-knowledge-ui-design.md index 8dfc41de..4cb322cc 100644 --- a/docs/features/knowledge-base/external-knowledge-ui-design.md +++ b/docs/features/knowledge-base/external-knowledge-ui-design.md @@ -4,13 +4,90 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 设计稿 | -| 版本 | 0.1 | -| 日期 | 2026-09-03 | +| 状态 | Portal 与控件已修正,Dify 完整 App 真实问答及当前自动回归已通过 | +| 版本 | 0.2 | +| 日期 | 2026-09-13 | | 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) | | 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-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. 信息架构 外部知识库不增加左侧主导航和知识库一级页签。现有知识库页面结构保持: @@ -82,7 +159,7 @@ RAGFlow ## 4. 管理外部实例 -点击“管理外部实例”打开大尺寸 Modal。Modal 使用标题、可滚动内容和固定操作区,遵循 +点击“管理外部实例”打开最大 560px 的紧凑 Modal。Modal 使用标题、可滚动内容和粘附底部操作区,遵循 全局焦点陷阱、Escape、背景隔离和焦点恢复规则。 ```text @@ -237,6 +314,14 @@ Rerank 用量。` 右侧配置固定分为“通用配置”和“{Provider} 配置”,不创建嵌套卡片。高级字段可以使用 一个折叠区,但核心检索方式、阈值和 RAGFlow 图谱检索保持直接可见。 +第一版遵循 [PRD 产品决策](./external-knowledge-prd.md#2-产品决策)的只读范围。面板固定说明: +`参数仅保存在 GoodBuddy,用于此绑定的后续检索,不会修改远端知识库设置。` +编辑页保存按钮使用“保存检索参数”;恢复默认仅重置当前表单,保存后作用于当前绑定。 +远端索引、模型、图谱构建和 Pipeline 配置只展示状态,不提供编辑或执行操作。 + +Dify 的“使用知识库默认配置”开启时说明:`每次检索沿用远端当前设置,远端设置变化会影响结果。` +关闭后,客户端显式检索参数随请求发送,不写回远端默认配置。 + 控件选择: - 开关:图谱检索、Score 阈值启用、Rerank、使用 Dify 知识库默认配置。 @@ -267,11 +352,11 @@ Rerank 用量。` | 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` 仅按结果实际存在显示 | | 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 列表项应区分三个图谱状态:“未配置”“构建中或未完成”“可用于图谱检索”。不能 -仅因 `parser_config.graphrag.use_graphrag = true` 就显示为可用。当前实测中 17 个库配置了 -GraphRAG,只有 15 个存在完成证据。 +仅因 `parser_config.graphrag.use_graphrag = true` 就显示为可用。实测数量见 +[实施进度](./progress.md#验证证据),界面按当前远端知识库的配置和完成状态判断。 Knowledge Compilation 的请求被服务器接受但当前没有库提供配置证据,因此开关显示为禁用, 说明为“当前知识库未配置 Knowledge Compilation”。返回 200 或零结果都不能改变此状态。 diff --git a/docs/features/knowledge-base/external-knowledge-validation-2026-09-13.json b/docs/features/knowledge-base/external-knowledge-validation-2026-09-13.json new file mode 100644 index 00000000..dcf91833 --- /dev/null +++ b/docs/features/knowledge-base/external-knowledge-validation-2026-09-13.json @@ -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" + } +} diff --git a/docs/features/knowledge-base/prd.md b/docs/features/knowledge-base/prd.md index 9cd0350e..737bb150 100644 --- a/docs/features/knowledge-base/prd.md +++ b/docs/features/knowledge-base/prd.md @@ -13,6 +13,10 @@ ## 1. 背景 +本文负责本地检索与分块增强。当前工作区另已接入外部实例、绑定和统一检索,其产品范围见 +[外部知识库 PRD](./external-knowledge-prd.md),实现与待验收状态见[实施进度](./progress.md)。 +外部绑定不进入下述本地分块、向量索引和图谱维护流程。 + GoodBuddy 已具备本地多知识库、文件与目录同步、网页导入、SQLite FTS5、 OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来源引用。现有实现 优先建立了本地数据主权、安全边界和跨 Runtime 工具授权,但用户仍难以稳定 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index 1328b071..e28dcdec 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -2,7 +2,8 @@ ## 已验证实现 -截至 2026-09-05: +截至 2026-09-05 的本地知识库验证记录如下。外部知识库当前实现和验证范围见后文 +2026-09-13 记录。 - 知识库选择按 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 核对与真实实例复测: - 官方资料链接与版本差异见[技术设计第 9 节](./external-knowledge-technical-design.md#9-provider-映射)。 @@ -37,9 +188,12 @@ - 修正探测脚本将 Dify/RAGFlow 失败记成零命中的问题,增加实际调用与成功标记,统一 FastGPT 地址规范化并记录分数结构、响应中的重排状态。未修改产品客户端;旧 FastGPT - 检索数组包装、数组评分映射及上一轮发现的取消分类问题仍待处理。 + 检索数组包装、数组评分映射及上一轮发现的取消分类问题在当时仍待处理;当前客户端已修正, + 当前服务和本机 HTTP MCP 的真实 Provider 复验见 2026-09-13 记录,旧包装等特定响应变体 + 仍不能仅凭成功计数认定覆盖。 - 三组探测测试共 43 项通过,包含两份新报告的脱敏与只读操作检查;`npm run typecheck`、`npm run lint` 通过。 - `npm test` 先后达到 60 秒和 240 秒执行时限,均未输出汇总,全量回归尚未完成。 + `npm test` 在该历史轮次先后达到 60 秒和 240 秒执行时限,均未输出汇总;当前最终全量 + 已通过,见 2026-09-13 记录。 - 本轮未验证真实 401/403/404/429、超时、取消、Scoped Key、分页、元数据过滤或候选数 参数效果。探测脚本不打包到桌面或 GoodBuddy Agent,本次未改变远程 Runtime 生产路径。 @@ -92,8 +246,17 @@ ## 剩余工作 -- 外部 Dify、FastGPT 和 RAGFlow 已完成 Main 适配基础;实例凭据持久化、绑定迁移、IPC、 - Renderer 管理界面、`retrieveMany`、聊天预检索和 MCP 网关仍未接入,当前不能由用户使用。 +- 完成 FastGPT/RAGFlow 桌面专属配置及实际参数效果检查;补充混合库、真实聊天 + `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。 - PDF.js 在测试环境会提示未配置 `standardFontDataUrl`;文本、页码定位与检索结果不受 diff --git a/docs/features/knowledge-base/ui-design.md b/docs/features/knowledge-base/ui-design.md index e71224ca..8fb1ad0d 100644 --- a/docs/features/knowledge-base/ui-design.md +++ b/docs/features/knowledge-base/ui-design.md @@ -8,6 +8,10 @@ ## 2. 信息架构 +当前列表同时容纳本地库与外部绑定,页面增加“管理外部实例”和来源筛选。以下详情结构、 +资料导入和索引状态适用于本地库;外部创建、配置、检索测试及引用的当前实现与待验收项见 +[外部知识库 UI 设计](./external-knowledge-ui-design.md)。 + ```text 知识库 ├─ 知识库列表 @@ -23,9 +27,9 @@ ## 3. 首次使用 -没有知识库时,页面空状态显示唯一的“新建知识库”主操作。创建表单默认只展示名称和 -描述;存储方式与知识图谱放入折叠的“高级设置”。创建完成后进入“文档与来源”,用户 -可以导入文件、文件夹或 URL。 +没有知识库时,页面空状态显示“新建知识库”主操作。创建时先选择类型;本地类型默认 +展示名称和描述,存储方式与知识图谱放入折叠的“高级设置”。本地库创建完成后进入 +“文档与来源”,用户可以导入文件、文件夹或 URL。 文档处理完成后,标题区“用于当前对话”可用。该操作把当前知识库加入当前 Conversation 的检索范围,返回对话并聚焦输入框。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 70c32d2d..b41279b5 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -263,8 +263,24 @@ Execute 直接启动已签名 Runtime: - 本地 SDK 通过 Task `metadata.sessionId` 关联子会话,复用稳定消息块 ID 保存过程。 固定 OpenCode 1.18.29 的 ACP 不转发子会话,因此 Agent 的模型桥 helper 在受管临时目录 写入事件插件,并通过既有 ACP `session/update` 的 `_meta.goodbuddySubagentEvent` - 转发到所属 Task;不新增端口,不修改签名 Runtime 包。插件文件随 helper 退出清理, + 转发到所属 Task;不修改签名 Runtime 包。插件文件随 helper 退出清理, 事件继续经过既有 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 所带的所属 `toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。 @@ -321,6 +337,11 @@ Execute 直接启动已签名 Runtime: 权威。启动后 Renderer 先订阅恢复进度,再触发每项目恢复。项目选择器依次显示网络、 Agent、Runtime、cursor、完成或失败/重试;只阻塞受影响项目的发送和队列,已有历史与 其他项目保持可用。 +- 完成提示是项目选择器内恢复进度的收尾反馈,首次收到该项目该 `requestId` 的完成状态后 + 显示 3 秒,然后从收起按钮和展开菜单同时隐藏;不另发全局成功通知。计时由 + `ProjectSwitcher` 持有,切换项目、开合菜单或重复状态快照不延长期限,也不重放已隐藏的 + 提示;新 `requestId` 的恢复可以再次展示。进行中状态和失败/就地重试持续保留。 + 隐藏仅作用于展示层,`App` 保留完成业务状态,继续用于发送、队列恢复和事件顺序判断。 - 正常应用退出对已接受托管 SSH 请求执行 detach,不等待远端完成;本地请求和尚未接受的 请求仍按原行为取消。用户显式“停止”始终发送稳定 operation cancellation,并等待 Agent 语义终态。已进入 Agent-owned `run` 但 start/attach 响应仍不确定的请求继续保留为 diff --git a/docs/quality/README.md b/docs/quality/README.md new file mode 100644 index 00000000..82f5e9fa --- /dev/null +++ b/docs/quality/README.md @@ -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 测试不替代发布检查 | diff --git a/docs/quality/electron-ui-automation.md b/docs/quality/electron-ui-automation.md new file mode 100644 index 00000000..13a6cca7 --- /dev/null +++ b/docs/quality/electron-ui-automation.md @@ -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/截图是否仍可取得。 diff --git a/src/agent-daemon/agent-owned-acp-prompt.ts b/src/agent-daemon/agent-owned-acp-prompt.ts index e703a1ba..1ac1c11e 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.ts @@ -10,6 +10,7 @@ import { type SessionNotification } from '@agentclientprotocol/sdk' import { canonicalJson } from '../shared/agent-protocol/canonical' +import { remoteQuestionSchema, type RemoteQuestionResponse } from '../shared/remote-question-contracts' import type { RemoteOwnedPromptStartRequest, RemoteOwnedPromptStartResult @@ -57,6 +58,10 @@ export class AgentOwnedAcpPrompt { #promptPromise?: Promise #closed = false #initialized = false + readonly #questions = new Map() + #acceptQuestions = false #active?: { operationId: string } @@ -94,6 +99,8 @@ export class AgentOwnedAcpPrompt { return } const error = new Error('ACP Runtime process exited') + this.#acceptQuestions = false + this.#questions.clear() controller?.error(error) controller = undefined rejectProcessExit(error) @@ -213,6 +220,7 @@ export class AgentOwnedAcpPrompt { this.#active = { operationId: request.operationId } + this.#acceptQuestions = true const begun = this.#options.transcript.begin({ bindingId: this.#options.bindingId, operationId: request.operationId, @@ -277,6 +285,8 @@ export class AgentOwnedAcpPrompt { } ) .finally(() => { + this.#questions.clear() + this.#acceptQuestions = false this.#active = undefined }) void this.#promptPromise.catch(() => undefined) @@ -284,6 +294,8 @@ export class AgentOwnedAcpPrompt { } async cancel(): Promise { + this.#acceptQuestions = false + this.#questions.clear() if (this.#sessionId !== undefined && !this.#closed) { await this.#connection.cancel({ sessionId: this.#sessionId }) } @@ -293,6 +305,29 @@ export class AgentOwnedAcpPrompt { this.#clear() } + async respondToQuestion(request: RemoteQuestionResponse): Promise { + 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 { return { requestPermission: async (request) => @@ -306,12 +341,38 @@ export class AgentOwnedAcpPrompt { } #handleSessionUpdate(notification: SessionNotification): void { + if (this.#closed || !this.#active) return if ( notification.sessionId !== this.#sessionId && this.#sessionId !== undefined ) { 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({ bindingId: this.#options.bindingId, operationId: this.#requireActive().operationId, @@ -371,6 +432,8 @@ export class AgentOwnedAcpPrompt { return } this.#closed = true + this.#acceptQuestions = false + this.#questions.clear() this.#unsubscribeOutput() this.#unsubscribeExit() } diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts index 108d800f..dfa03f83 100644 --- a/src/agent-daemon/model-bridge-helper.ts +++ b/src/agent-daemon/model-bridge-helper.ts @@ -871,7 +871,8 @@ function credentialFreeHelperEnvironment( 'XDG_STATE_HOME' ] as const const environment: NodeJS.ProcessEnv = { - OPENCODE_CONFIG_CONTENT: config + OPENCODE_CONFIG_CONTENT: config, + OPENCODE_ENABLE_QUESTION_TOOL: 'true' } for (const name of allowed) { const value = source[name] diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts index 4788070c..5cb2b389 100644 --- a/src/agent-daemon/model-bridge.test.ts +++ b/src/agent-daemon/model-bridge.test.ts @@ -117,6 +117,7 @@ describe('model bridge loopback helper', () => { expect(args).toEqual(['acp']) expect(options).toMatchObject({ shell: false, stdio: 'inherit' }) expect(options.env).toMatchObject({ + OPENCODE_ENABLE_QUESTION_TOOL: 'true', ...resources, LANG: profile.env.LANG, LC_ALL: profile.env.LC_ALL, diff --git a/src/agent-daemon/opencode-subagent-plugin.test.ts b/src/agent-daemon/opencode-subagent-plugin.test.ts index cfa17ba0..3b6e393f 100644 --- a/src/agent-daemon/opencode-subagent-plugin.test.ts +++ b/src/agent-daemon/opencode-subagent-plugin.test.ts @@ -1,13 +1,74 @@ import { runInNewContext } from 'node:vm' +import { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { openCodeSubagentPluginSource } from './opencode-subagent-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 () => { const write = vi.fn() const factory = runInNewContext( - `(${openCodeSubagentPluginSource().replace('export default ', '')})`, - { process: { stdout: { write } } } + `(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`, + { process: { stdout: { write } }, createServer: undefined, randomBytes: undefined } ) const hooks = await factory() const event = { diff --git a/src/agent-daemon/opencode-subagent-plugin.ts b/src/agent-daemon/opencode-subagent-plugin.ts index 2153e345..1ca5171e 100644 --- a/src/agent-daemon/opencode-subagent-plugin.ts +++ b/src/agent-daemon/opencode-subagent-plugin.ts @@ -1,20 +1,98 @@ /** - * OpenCode's ACP adapter omits native child sessions. This plugin forwards - * their SDK events as ACP metadata on the owning Task, using the same stdio - * stream and prompt lifetime as the built-in adapter. + * OpenCode's ACP adapter omits native questions and child sessions. Native + * events use ACP metadata; question replies use a process-local capability. */ 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 + } + 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() + const pending = new Map() + 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((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({ + dispose: async () => { + closed = true + pending.clear() + tasks.clear() + closeServer?.() + }, event: async ({ event }: { event: { type: string properties: { + id?: string + requestID?: string + status?: { type?: string } sessionID?: string part?: { + sessionID?: string type: string tool?: string callID?: string @@ -25,14 +103,69 @@ function plugin() { } } }) => { 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() + 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 - let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined + const sessionId = properties.sessionID ?? part?.sessionID + let owner = sessionId ? tasks.get(sessionId) : undefined if ( - !owner && event.type === 'message.part.updated' && + event.type === 'message.part.updated' && 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 if (child) tasks.set(child, owner) } diff --git a/src/agent-daemon/protocol-server.test.ts b/src/agent-daemon/protocol-server.test.ts index c849d87e..57662b03 100644 --- a/src/agent-daemon/protocol-server.test.ts +++ b/src/agent-daemon/protocol-server.test.ts @@ -1,5 +1,5 @@ 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 { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -18,6 +18,8 @@ import { import { ControllerRegistry, type ControllerLease } from './controller-registry' import { EventJournal } from './event-journal' import { AgentProtocolServer } from './protocol-server' +import { WorkspacePathAccess } from './workspace-path-access' +import { remoteWorkspaceReadTextRequestSchema } from '../shared/remote-agent-contracts' const temporaryPaths: string[] = [] const closeMethod = 'channel/close' @@ -341,6 +343,51 @@ describe('AgentProtocolServer connection bounds', () => { 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 () => { const methods = Object.fromEntries( [ diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts index 211d4852..5ab97bf6 100644 --- a/src/agent-daemon/protocol-server.ts +++ b/src/agent-daemon/protocol-server.ts @@ -1137,7 +1137,7 @@ function typedErrorCode(error: unknown): string | undefined { typeof error !== 'object' || !('code' in error) || 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 } diff --git a/src/agent-daemon/remote-question-binary.test.ts b/src/agent-daemon/remote-question-binary.test.ts new file mode 100644 index 00000000..f41ef4fc --- /dev/null +++ b/src/agent-daemon/remote-question-binary.test.ts @@ -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(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(resolve => { if (child.exitCode !== null) resolve(); else child.once('exit', () => resolve()) }) + transcript.close() + model.closeAllConnections() + await new Promise(resolve => model.close(() => resolve())) + await rm(root, { recursive: true, force: true }) + } +}, 60_000) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 699fd049..36c4146f 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { remoteQuestionResponseSchema } from '../shared/remote-question-contracts' import { createHash } from 'node:crypto' import { AGENT_PROTOCOL_LIMITS, @@ -421,6 +422,20 @@ export class RuntimeAcpBackend { this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)), 'runtime/attachPrompt': (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) => this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)), 'runtime/ackPromptTranscript': (params, context) => @@ -1476,10 +1491,12 @@ export class RuntimeAcpBackend { 'process' ) } - return this.#options.semanticPrompts.page({ + const page = this.#options.semanticPrompts.page({ ...request, controllerId: context.controller.controllerId }) + const binding = this.#bindings.get(request.bindingId) + return { ...page, pendingQuestions: binding?.ownedAcp?.pendingQuestions(request.operationId) ?? [] } } #ackOwnedPrompt( diff --git a/src/agent-daemon/runtime-composition.test.ts b/src/agent-daemon/runtime-composition.test.ts index 6152d8fc..6f305d79 100644 --- a/src/agent-daemon/runtime-composition.test.ts +++ b/src/agent-daemon/runtime-composition.test.ts @@ -170,6 +170,7 @@ describe('production Runtime composition', () => { 'runtime/preparePrompt', 'runtime/reconcilePrompt', 'runtime/replayAcpChannel', + 'runtime/respondToQuestion', 'runtime/resumeAcpChannel', 'runtime/startPrompt' ]) diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index 851796c0..118ffeb2 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -2872,6 +2872,82 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { ).not.toHaveProperty('modelBridge') }) + it('restores live questions after transcript ACK, scopes replies and ignores answered history', async () => { + const fixture = ownedChannel() + const reply = vi.fn>() + .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 () => { const fixture = ownedChannel() const first = fixture.instance.run( diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index b2914d78..c3aee6dd 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -1,4 +1,6 @@ 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 { ClientSideConnection, @@ -137,6 +139,7 @@ export class RemotePromptRecoveryUnavailableError extends Error { } type ActivePrompt = { + reportedQuestions?: Set subagentProgress?: OpenCodeSubagentProgress requestId: string operationId: string @@ -352,6 +355,7 @@ export class AcpRemoteRuntime implements AgentRuntime { Promise >() private readonly activePrompts = new Map() + private readonly pendingQuestions = new Map() private readonly sessionReservations = new Set() private readonly drainWaiters = new Set<() => void>() private readonly contexts = new Map() @@ -2070,6 +2074,21 @@ export class AcpRemoteRuntime implements AgentRuntime { prompt: ActivePrompt, update: SessionUpdate ): 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 (!prompt.subagentProgress) { prompt.subagentProgress = new OpenCodeSubagentProgress( @@ -2295,6 +2314,25 @@ export class AcpRemoteRuntime implements AgentRuntime { return undefined } + async respondToQuestion(questionId: string, answers: AgentQuestionAnswer[] = []): Promise { + 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( event: RuntimePublicEvent, bindingId: string, @@ -2553,7 +2591,8 @@ export class AcpRemoteRuntime implements AgentRuntime { transcriptEvent.payload, 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) { publicEvents.push(this.limitEvent(mapped)) } @@ -2672,6 +2711,23 @@ export class AcpRemoteRuntime implements AgentRuntime { transcriptPollDelayMs = 100 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 ( page.state === 'completed' || page.state === 'failed' || @@ -2699,6 +2755,7 @@ export class AcpRemoteRuntime implements AgentRuntime { } signal.removeEventListener('abort', cancel) prompt.open = false + this.clearPromptQuestions(prompt) this.activePrompts.delete(binding.bindingId) } } @@ -3043,6 +3100,7 @@ export class AcpRemoteRuntime implements AgentRuntime { session.binding = binding } prompt.open = false + this.clearPromptQuestions(prompt) prompt.interrupt = undefined prompt.wake?.() prompt.wake = undefined @@ -3251,6 +3309,7 @@ export class AcpRemoteRuntime implements AgentRuntime { new Error('远端 Runtime 已关闭,活动请求结果需要核对') ) prompt.open = false + this.clearPromptQuestions(prompt) prompt.wake?.() this.discardUpdates(prompt) } diff --git a/src/main/agent/direct-model-ripgrep.test.ts b/src/main/agent/direct-model-ripgrep.test.ts new file mode 100644 index 00000000..ad40d11e --- /dev/null +++ b/src/main/agent/direct-model-ripgrep.test.ts @@ -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() + 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('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 = {}, 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) + }) +}) diff --git a/src/main/agent/direct-model-ripgrep.ts b/src/main/agent/direct-model-ripgrep.ts index 19b63d9f..f3264f08 100644 --- a/src/main/agent/direct-model-ripgrep.ts +++ b/src/main/agent/direct-model-ripgrep.ts @@ -52,6 +52,7 @@ function runRipgrep( if ( processError && processError.code !== 1 && + processError.code !== 2 && processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ) { reject( @@ -225,7 +226,17 @@ export async function searchWorkspaceWithRipgrep( identity.canonicalDisplayPath, 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( result.stdout, input.maxResults, @@ -236,4 +247,13 @@ export async function searchWorkspaceWithRipgrep( input.maxResults, 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 } diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index 7ee5d565..5ae7b79b 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -29,32 +29,22 @@ const firstLibraryId = '11111111-1111-4111-8111-111111111111' const secondLibraryId = '22222222-2222-4222-8222-222222222222' function createService() { - const searchHybridMany = vi.fn( + const retrieveMany = vi.fn( async (libraryIds: readonly string[]) => libraryIds.map((knowledgeBaseId, index) => ({ knowledgeBaseId, - result: { - document: { - id: `33333333-3333-4333-8333-33333333333${index}`, - title: `文档 ${index}` - }, - source: { - displayName: `来源 ${index}`, - location: `/private/${index}` - }, - chunk: { - id: `44444444-4444-4444-8444-44444444444${index}`, - location: `第 ${index + 1} 段` - }, + response: { diagnostics: {}, results: [{ + knowledgeBaseId, + documentId: `33333333-3333-4333-8333-33333333333${index}`, + documentTitle: `文档 ${index}`, + sourceDisplayName: `来源 ${index}`, + chunkId: `44444444-4444-4444-8444-444444444440`, + location: `第 ${index + 1} 段`, snippet: `匹配 ${index}`, rank: index + 1, - retrieval: { - score: 0.5, - channels: ['fts'] as const, - lexicalRank: 1, - evidenceIds: [] - } - } + channels: ['fts'] as const, + scores: { fusedScore: 0.5, ftsRank: 1 } + }] } })) ) const service = { @@ -72,9 +62,9 @@ function createService() { } ] }, - searchHybridMany + retrieveMany } as unknown as KnowledgeService - return { service, searchHybridMany } + return { service, retrieveMany } } function customMcpServer( @@ -189,24 +179,19 @@ function testTool(name: string): Tool { const gateways: KnowledgeMcpGateway[] = [] it('honors a requested knowledge result count above eight', async () => { - const { service, searchHybridMany } = createService() - const [sample] = await searchHybridMany([firstLibraryId]) - searchHybridMany.mockClear() - searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({ - ...sample!, - result: { - ...sample!.result, - chunk: { ...sample!.result.chunk, id: crypto.randomUUID() }, - rank: index + 1 - } - }))) + const { service, retrieveMany } = createService() + const [sample] = await retrieveMany([firstLibraryId]) + retrieveMany.mockClear() + retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({ + ...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1 + })) } }]) const gateway = new KnowledgeMcpGateway(service) gateways.push(gateway) const controller = new AbortController() const token = gateway.grant('large-search', [firstLibraryId], controller.signal)! const references = await gateway.search(token, { query: 'evidence', limit: 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() }) const databases: AssistantDatabase[] = [] @@ -621,7 +606,7 @@ describe('KnowledgeMcpGateway', () => { }) 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) gateways.push(gateway) const token = gateway.grant( @@ -652,10 +637,9 @@ describe('KnowledgeMcpGateway', () => { limit: 1 }) - expect(searchHybridMany).toHaveBeenCalledWith( + expect(retrieveMany).toHaveBeenCalledWith( [secondLibraryId], '要找什么', - 1, expect.any(AbortSignal) ) expect(references).toEqual([ diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index ac105f86..572e8adb 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto' +import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference' import { createServer, type IncomingMessage, @@ -24,7 +25,6 @@ import type { BrowserTabSummary, KnowledgeSearchReference } from '../../shared/contracts' -import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text' import { knowledgeToolNames, knowledgeScopedDataToolCatalog, @@ -273,13 +273,7 @@ function textContent(value: string): MagicNoteRichContent { } function referenceKey(reference: KnowledgeSearchReference): string { - return [ - reference.libraryId, - reference.documentId, - reference.chunkId ?? '', - reference.locator ?? '', - reference.snippet - ].join('\0') + return knowledgeReferenceKey(reference) } function ensureBoundedCustomMcpResult(result: unknown): CallToolResult { @@ -681,20 +675,22 @@ export class KnowledgeMcpGateway { const { query, limit } = knowledgeSearchTool.inputSchema.parse( input ) - const effectiveSignal = signal - ? AbortSignal.any([signal, capability.signal]) - : capability.signal + const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])]) effectiveSignal.throwIfAborted() const libraries = this.knowledgeService.database.listKnowledgeBases(500) const libraryNames = new Map( libraries.map((library) => [library.id, library.name]) ) - const results = await this.knowledgeService.searchHybridMany( + const outcomes = await this.knowledgeService.retrieveMany( capability.libraryIds, query, - limit, 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 seen = new Set() for (const { knowledgeBaseId, result } of results.sort( @@ -704,22 +700,8 @@ export class KnowledgeMcpGateway { break } const reference: KnowledgeSearchReference = { - libraryId: knowledgeBaseId, - libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库', - 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) + ...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'), + ...(warnings.length ? { warnings } : {}) } const key = referenceKey(reference) if (seen.has(key)) { @@ -1473,13 +1455,14 @@ export class KnowledgeMcpGateway { private async callScopedTool( token: string, name: ScopedDataToolName, - input: unknown + input: unknown, + signal?: AbortSignal ): Promise> { switch (name) { case 'knowledge_list': return { libraries: this.listLibraries(token, input) } case 'knowledge_search': - return { references: await this.search(token, input) } + return { references: await this.search(token, input, signal) } case 'note_list': return { notes: this.listMagicNotes(token, input) } case 'note_get': @@ -1641,7 +1624,8 @@ export class KnowledgeMcpGateway { await this.callScopedTool( token, name as ScopedDataToolName, - parsedInput + parsedInput, + extra.signal ) ) } diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index eab19978..d0e280c0 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -4084,6 +4084,104 @@ describe('ModelAgentRuntime', () => { 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(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 () => { const streams = [ createSseEventStream([ diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index a8157778..41b45d7c 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -68,6 +68,7 @@ vi.mock('../capabilities/mcp-client-transport', () => ({ import { ModelToolProvider, + RecoverableModelToolError, type ModelToolCallContext, type ModelSubagentRequestContext, type ModelToolResult @@ -461,6 +462,158 @@ describe('ModelToolProvider', () => { ).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 () => { const readText = vi.fn(async () => ({ path: 'remote.txt', diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index ede261b7..31986b67 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -1731,25 +1731,74 @@ export class ModelToolProvider implements ModelToolProviderLike { throw error } } - if (name === 'workspace_rg') { - if (!this.programming.ripgrepExecutablePath) { - throw new Error('GoodBuddy 内置 ripgrep 不可用') - } - const input = ripgrepInputSchema.parse(argumentsValue) - return createTextToolResult( - await searchWorkspaceWithRipgrep( - this.programming.ripgrepExecutablePath, - input, - this.workspaceAccess, - signal + if (name === 'workspace_rg' || name === 'workspace_read_text') { + try { + if (name === 'workspace_rg') { + if (!this.programming.ripgrepExecutablePath) { + throw new Error('GoodBuddy 内置 ripgrep 不可用') + } + const input = ripgrepInputSchema.parse(argumentsValue) + return createTextToolResult( + await searchWorkspaceWithRipgrep( + this.programming.ripgrepExecutablePath, + input, + this.workspaceAccess, + signal + ) + ) + } + const input = readInputSchema.parse(argumentsValue) + return createTextToolResult( + await readWorkspaceLines(this.workspaceAccess, input, signal) ) - ) - } - if (name === 'workspace_read_text') { - const input = readInputSchema.parse(argumentsValue) - return createTextToolResult( - await readWorkspaceLines(this.workspaceAccess, input, signal) - ) + } catch (error) { + signal.throwIfAborted() + if (!(error instanceof Error) || error.name === 'AbortError' || + (error.cause instanceof Error && error.cause.name === 'AbortError')) { + throw error + } + 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') { const input = applyPatchInputSchema.parse(argumentsValue) diff --git a/src/main/agent/opencode-runtime-permissions.test.ts b/src/main/agent/opencode-runtime-permissions.test.ts index 6027ef50..d65807d9 100644 --- a/src/main/agent/opencode-runtime-permissions.test.ts +++ b/src/main/agent/opencode-runtime-permissions.test.ts @@ -14,7 +14,7 @@ const cachedBinary = join( const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary 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 () => { const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-')) const workspace = join(root, 'workspace') @@ -50,7 +50,8 @@ it.skipIf(!existsSync(binaryPath))( tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } } } } 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' } else if (results.some(result => result.tool_call_id === 'native-task')) { tool = { id: 'native-question', name: 'question', arguments: { questions: [ @@ -74,7 +75,11 @@ it.skipIf(!existsSync(binaryPath))( role: 'assistant', tool_calls: [{ index: 0, id: tool.id, type: 'function', function: { 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], [{}, tool ? 'tool_calls' : 'stop'] ]) { @@ -102,17 +107,24 @@ it.skipIf(!existsSync(binaryPath))( try { for (const mode of ['execute', 'ask'] as const) { let text = '' + const pending: Array<{ id: string; answers: string[][] }> = [] for await (const event of runtime.run({ requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS' }, AbortSignal.timeout(30_000))) { if (event.type === 'text') text += event.delta if (event.type === 'question') { - expect(event.questions).toHaveLength(2) - await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']]) + pending.push({ id: event.questionId, answers: event.questions.length === 2 + ? [['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(pending).toHaveLength(mode === 'execute' ? 2 : 0) } expect(readVerified).toBe(true) expect(answerVerified).toBe(true) diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index baee0545..ea7e672d 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -2281,6 +2281,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => { 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 () => { const setup = runClient([]); ( diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 321ebb51..c8b4487b 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -2464,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime { return false; }; const reportedMessageIds = new Set(); + const childTaskIdsBySession = new Map(); let waitingForRetry = false; for await (const event of subscription.stream) { const childProgress = subagentProgress.update(event); @@ -2621,6 +2622,10 @@ export class OpenCodeRuntime implements AgentRuntime { ...(subagent ? { 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); toolStates.get(callId)!.subagent = retained; yield retained; @@ -2693,6 +2698,7 @@ export class OpenCodeRuntime implements AgentRuntime { requestId: request.requestId, type: "question", questionId: publicQuestionId, + childTaskId: childTaskIdsBySession.get(event.properties.sessionID), questions: questionRequest.questions.map((question) => ({ header: question.header, question: question.question, diff --git a/src/main/agent/remote-runtime-channel.ts b/src/main/agent/remote-runtime-channel.ts index 38871a8d..ecfc43c8 100644 --- a/src/main/agent/remote-runtime-channel.ts +++ b/src/main/agent/remote-runtime-channel.ts @@ -18,6 +18,7 @@ import type { remoteSemanticTranscriptPageResultSchema, } from '../../shared/remote-agent-contracts' import type { z } from 'zod' +import type { RemoteQuestionResponse } from '../../shared/remote-question-contracts' import type { ModelBridgePolicy } from '../../shared/model-bridge-contracts' @@ -91,6 +92,7 @@ export interface RemoteModelBridgeSession { * The runtime intentionally receives no SSH client, socket or command API. */ export interface RemoteRuntimeChannel { + respondToQuestion?(request: RemoteQuestionResponse): Promise readonly input: ReadableStream readonly output: WritableStream readonly generation: number diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index cec0101a..d02e136a 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -132,6 +133,148 @@ function claimManualScheduleQueueItem( } 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 () => { const database = await createDatabase() const project = database.createProject({ @@ -3460,7 +3603,13 @@ describe('AssistantDatabase', () => { snippet: '可以继续恢复。', rank: 1, 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, { @@ -3489,7 +3638,9 @@ describe('AssistantDatabase', () => { resultCount: 1 }, sourceReferences: [ - expect.objectContaining({ documentName: '恢复说明.md' }) + expect.objectContaining({ documentName: '恢复说明.md' }), + expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'first' }) }), + expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second' }) }) ], subagents: [ expect.objectContaining({ diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 7e9bbf44..a49280b6 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { knowledgeReferenceKey } from '../../shared/knowledge-reference' import { DatabaseSync } from 'node:sqlite' import { builtInDefaultProjectSeedDescription, @@ -588,6 +589,14 @@ const taskSelect = `SELECT tasks.*, LIMIT 1) AS active_run_status 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 { return { id: row.id, @@ -1470,16 +1479,7 @@ function reduceRecoveredAgentEvent( : undefined } } else if (event.type === 'source-references') { - const key = ( - reference: (typeof event.references)[number] - ): string => - [ - reference.libraryId, - reference.documentId, - reference.chunkId ?? '', - reference.locator ?? '', - reference.snippet - ].join('\0') + const key = knowledgeReferenceKey const incoming = [ ...new Map( event.references.map((reference) => [key(reference), reference]) @@ -2612,8 +2612,15 @@ export class AssistantDatabase { updated_at FROM conversations WHERE status = 'active' - ORDER BY updated_at DESC - LIMIT 100` + AND ( + 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[] const messageStatement = database.prepare( @@ -4195,8 +4202,12 @@ export class AssistantDatabase { .prepare( `${taskSelect} WHERE visible = 1 - ORDER BY created_at DESC - LIMIT ?` + AND ( + 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[] return rows.map(toTask) diff --git a/src/main/index.ts b/src/main/index.ts index 38043588..f54a170e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -854,6 +854,7 @@ if (hasSingleInstanceLock) { runtimeExtensionStore.markStartupFailed(extensionIds) }) const startupKnowledgeService = new KnowledgeService({ + credentialCipher: secureCipher, databasePath: join(app.getPath('userData'), 'knowledge.sqlite'), managedRoot: join(app.getPath('userData'), 'knowledge'), extractStructured: createModelGraphExtractor(settingsStore), diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index bd5c64bc..de1573cd 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -25,6 +25,7 @@ import type { } from '../shared/ssh-host-contracts' import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' +import { KnowledgeService } from './knowledge/knowledge-service' import { BrowserNavigationStoppedError } from './browser/browser-service' import { registerIpcHandlers, @@ -3087,6 +3088,7 @@ describe('registerIpcHandlers knowledge snapshot ontology', () => { it('exposes per-library ontology settings and rebuild state', async () => { const libraryId = '11111111-1111-4111-8111-111111111111' const knowledgeService = { + database: { externalStore: { listBindings: () => [] } }, snapshot: vi.fn(() => ({ libraries: [ { @@ -8236,7 +8238,7 @@ describe('registerIpcHandlers agent terminal state', () => { 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 documentId = '33333333-3333-4333-8333-333333333333' const chunkId = '44444444-4444-4444-8444-444444444444' @@ -8291,15 +8293,16 @@ describe('registerIpcHandlers agent terminal state', () => { results: [ { knowledgeBaseId: libraryId, - documentId, - sourceId: '55555555-5555-4555-8555-555555555555', - chunkId, + documentId: external ? undefined : documentId, + sourceId: external ? undefined : '55555555-5555-4555-8555-555555555555', + 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', sourceDisplayName: '产品手册', sourceType: 'file' as const, location: '第 2 节', snippet: '离线部署需要先校验安装包', - relevance: 0.9, + relevance: external ? undefined : 0.9, rank: 1, channels: ['fts' as const], 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 () => [ { knowledgeBaseId: libraryId, response: retrievalResponse } ]) @@ -8337,6 +8344,7 @@ describe('registerIpcHandlers agent terminal state', () => { runtimeId: 'model', capability: 'chat', supportsToolExecution: true, + supportsScopedDataTools: !external, run }, undefined, @@ -8387,20 +8395,19 @@ describe('registerIpcHandlers agent terminal state', () => { expect.objectContaining({ type: 'knowledge-retrieval', state: 'succeeded', - resultCount: 1 + resultCount: external ? 2 : 1 }), expect.objectContaining({ type: 'source-references', - references: [ - expect.objectContaining({ - chunkId, - documentId - }) - ] + references: external ? expect.arrayContaining([ + expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'remote-chunk' }) }), + expect.objectContaining({ external: expect.objectContaining({ remoteChunkId: 'second-chunk' }) }) + ]) : [expect.objectContaining({ chunkId, documentId })] }) ]) ) expect(run).toHaveBeenCalledOnce() + if (external) expect(run.mock.calls[0]![0].prompt).toContain('"locator":"Remote section 3"') await harness.dispose() }) @@ -8480,6 +8487,52 @@ describe('registerIpcHandlers agent terminal state', () => { 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(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) + 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 () => { const firstProfileId = '00000000-0000-4000-8000-000000000001' const secondProfileId = '00000000-0000-4000-8000-000000000002' diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 852cf96f..39ce0106 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 { app, BrowserWindow, @@ -992,6 +994,8 @@ function getKnowledgeSnapshot( } return { 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, name: library.name, description: library.description ?? '', @@ -1092,6 +1096,7 @@ function buildForcedKnowledgeEvidence( promptContext?: string references: KnowledgeSearchReference[] } { + const seen = new Set() const ranked = entries .flatMap((entry) => entry.response.results.map((result) => ({ @@ -1104,35 +1109,26 @@ function buildForcedKnowledgeEvidence( ) .sort( (left, right) => - right.result.relevance - left.result.relevance || - right.result.scores.fusedScore - - left.result.scores.fusedScore || - left.result.chunkId.localeCompare(right.result.chunkId) + left.result.rank - right.result.rank || + left.entry.libraryId.localeCompare(right.entry.libraryId) ) + .filter(({ entry, result }) => { + const key = knowledgeReferenceKey(toKnowledgeReference(result, entry.libraryName)) + if (seen.has(key)) return false + seen.add(key) + return true + }) .slice(0, 20) const references: KnowledgeSearchReference[] = ranked.map( ({ entry, result }, index) => ({ - libraryId: entry.libraryId, - libraryName: entry.libraryName, - 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 + ...toKnowledgeReference(result, entry.libraryName), + rank: index + 1 }) ) if (ranked.length === 0) { return { references } } - let remainingCharacters = 24_000 + let remainingCharacters = 48_000 const evidence: Array<{ citation: number library: string @@ -1161,7 +1157,7 @@ function buildForcedKnowledgeEvidence( library: item.entry.libraryName, document: item.result.documentTitle, source: item.result.sourceDisplayName, - locator: item.result.location, + locator: item.result.external?.location ?? item.result.location, text }) remainingCharacters -= text.length @@ -4317,12 +4313,7 @@ export function registerIpcHandlers( request.knowledgeCapabilityToken ) ?? []) ].map((reference) => [ - [ - reference.libraryId, - reference.documentId, - reference.chunkId ?? '', - reference.locator ?? '' - ].join('\0'), + knowledgeReferenceKey(reference), reference ]) ).values() @@ -4379,6 +4370,9 @@ export function registerIpcHandlers( libraryNames.get(knowledgeBaseId) ?? '知识库', 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) preflightReferences = evidence.references const usedChannels = [ @@ -4390,13 +4384,13 @@ export function registerIpcHandlers( ) ] 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) => `${entry.libraryName} · ${item.reason}`.slice( 0, 500 ) - ) + )] ) if (evidence.promptContext) { executionRequest = { @@ -4641,6 +4635,10 @@ export function registerIpcHandlers( runtime: selectedRuntime }) } + if (publicEvent.type === 'question-resolved' && + pendingAgentQuestions.get(publicEvent.questionId)?.requestId === request.requestId) { + pendingAgentQuestions.delete(publicEvent.questionId) + } if (publicEvent.type === 'error') { runtimeErrorEvent = publicEvent 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) => { assertTrustedSender(event, window) const libraryId = @@ -8064,30 +8072,19 @@ export function registerIpcHandlers( availableLibraries.map((library) => [library.id, library.name]) ) const results = ( - await knowledgeService.searchHybridMany( + await knowledgeService.retrieveMany( libraries, - value.query, - 6 + value.query ) - ).map(({ knowledgeBaseId, result }) => ({ - libraryId: knowledgeBaseId, - libraryName: names.get(knowledgeBaseId) ?? '知识库', - documentId: result.document.id, - chunkId: result.chunk.id, - documentName: result.document.title, - sourceName: result.source.displayName, - locator: result.chunk.location, - snippet: stripKnowledgeHighlightTags(result.snippet), - 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 + ) + const failures = results.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : []) + if (failures.length === results.length) throw new Error(failures.join('; ')) + const references = results.flatMap(({ knowledgeBaseId, response }) => response.results.map(result => ({ + ...toKnowledgeReference(result, names.get(knowledgeBaseId) ?? '知识库'), + ...(failures.length ? { warnings: failures } : {}) + }))) + if (!references.length && failures.length) throw new Error(failures.join('; ')) + return [...new Map(references.map(reference => [knowledgeReferenceKey(reference), reference])).values()] .sort((left, right) => left.rank - right.rank) .slice(0, 8) }) diff --git a/src/main/knowledge/external/external-knowledge-client.test.ts b/src/main/knowledge/external/external-knowledge-client.test.ts index f64e9dcf..13fd71f6 100644 --- a/src/main/knowledge/external/external-knowledge-client.test.ts +++ b/src/main/knowledge/external/external-knowledge-client.test.ts @@ -1,7 +1,9 @@ +import { createServer } from 'node:http' import { describe, expect, it, vi } from 'vitest' import { ExternalKnowledgeClient, - ExternalKnowledgeError + ExternalKnowledgeError, + type ExternalKnowledgeRetrievalConfig } from './external-knowledge-client' 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' }) }) - it('filters FastGPT folders and maps searchTest results', async () => { + it('retains FastGPT folders for navigation and maps searchTest results', async () => { const fetcher = vi.fn() .mockResolvedValueOnce(jsonResponse({ code: 200, @@ -83,7 +85,7 @@ describe('ExternalKnowledgeClient', () => { }) 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', { provider: 'fastgpt', @@ -98,6 +100,221 @@ describe('ExternalKnowledgeClient', () => { })]) 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().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().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().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().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().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().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().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().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() + 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((resolve) => { started = resolve }) + const fetcher = vi.fn().mockImplementation(async (_url, init) => { + if (phase === 'headers') return new Promise((_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().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().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((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((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((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().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().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().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().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().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 () => { diff --git a/src/main/knowledge/external/external-knowledge-client.ts b/src/main/knowledge/external/external-knowledge-client.ts index 5f8fb7fe..99b65535 100644 --- a/src/main/knowledge/external/external-knowledge-client.ts +++ b/src/main/knowledge/external/external-knowledge-client.ts @@ -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 = { - id: string - name: string - description?: string - graphEnabled?: boolean -} +export type { ExternalKnowledgeProvider, ExternalKnowledgeCatalogItem, ExternalKnowledgeCatalogPage } +export type ExternalKnowledgeRetrievalConfig = ExternalKnowledgeProviderConfig +export type ExternalKnowledgeRetrievalResult = ExternalKnowledgeRemoteResult -export type ExternalKnowledgeCatalogPage = { - items: ExternalKnowledgeCatalogItem[] - total?: number - hasMore: boolean -} - -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 type ExternalKnowledgeCatalogInput = { + page?: number + pageSize?: number + parentId?: string | null + search?: string } export class ExternalKnowledgeError extends Error { @@ -46,6 +23,8 @@ export class ExternalKnowledgeError extends Error { readonly code: | 'EXTERNAL_KB_NETWORK' | 'EXTERNAL_KB_TIMEOUT' + | 'EXTERNAL_KB_CANCELLED' + | 'EXTERNAL_KB_INCOMPATIBLE' | 'EXTERNAL_KB_AUTH' | 'EXTERNAL_KB_FORBIDDEN' | 'EXTERNAL_KB_NOT_FOUND' @@ -60,7 +39,7 @@ export class ExternalKnowledgeError extends Error { } } -type ExternalKnowledgeClientOptions = { +export type ExternalKnowledgeClientOptions = { provider: ExternalKnowledgeProvider baseUrl: string apiKey: string @@ -109,6 +88,7 @@ function endpoint(baseUrl: URL, path: string): URL { } 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 === 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') @@ -116,36 +96,42 @@ function errorForStatus(status: number): ExternalKnowledgeError { return new ExternalKnowledgeError('EXTERNAL_KB_SERVER', `External knowledge service returned HTTP ${status}`) } -async function readBoundedJson(response: Response, maximumBytes: number): Promise { +async function readBoundedJson(response: Response, maximumBytes: number, signal: AbortSignal): Promise { const declaredLength = Number(response.headers.get('content-length')) if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) { + void response.body?.cancel().catch(() => {}) throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large') } if (!response.body) { throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is empty') } const reader = response.body.getReader() + const cancel = (): void => { void reader.cancel().catch(() => {}) } + signal.addEventListener('abort', cancel, { once: true }) const chunks: Uint8Array[] = [] let total = 0 try { + signal.throwIfAborted() while (true) { const part = await reader.read() + signal.throwIfAborted() if (part.done) break total += part.value.byteLength if (total > maximumBytes) { - await reader.cancel() + cancel() throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is too large') } chunks.push(part.value) } } finally { + signal.removeEventListener('abort', cancel) reader.releaseLock() } const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8') try { return JSON.parse(body) as unknown - } catch (error) { - throw new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response is not valid JSON', { cause: error }) + } catch { + 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 } - async listKnowledgeBases(signal?: AbortSignal): Promise { + async listKnowledgeBases(signal?: AbortSignal, input: ExternalKnowledgeCatalogInput = {}): Promise { + 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') { const url = endpoint(this.normalizedBaseUrl, '/v1/datasets') - url.searchParams.set('page', '1') - url.searchParams.set('limit', '100') + url.searchParams.set('page', String(page)) + url.searchParams.set('limit', String(pageSize)) const body = asRecord(await this.request(url, { method: 'GET' }, signal)) const items = Array.isArray(body?.data) ? body.data : undefined if (!items) throw this.invalidResponse() 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), hasMore: body?.has_more === true } @@ -187,63 +180,81 @@ export class ExternalKnowledgeClient { if (this.provider === 'fastgpt') { const body = asRecord(await this.request( endpoint(this.normalizedBaseUrl, '/core/dataset/list'), - { method: 'POST', body: JSON.stringify({ pageNum: 1, pageSize: 100 }) }, + { method: 'POST', body: JSON.stringify({ parentId }) }, signal )) - const data = asRecord(body?.data) - const items = Array.isArray(body?.data) - ? body.data - : Array.isArray(data?.data) - ? data.data - : Array.isArray(data?.list) - ? data.list - : undefined + const items = Array.isArray(body?.data) ? body.data : undefined 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 { - items: datasets.slice(0, 100).map((item) => this.catalogItem(item, '_id', 'intro')), - total: finiteNumber(data?.total) ?? datasets.length, - hasMore: datasets.length > 100 + items: datasets.slice(offset, offset + pageSize), + total: datasets.length, + hasMore: datasets.length > offset + pageSize } } const url = endpoint(this.normalizedBaseUrl, '/api/v1/datasets') - url.searchParams.set('page', '1') - url.searchParams.set('page_size', '100') + url.searchParams.set('page', String(page)) + url.searchParams.set('page_size', String(pageSize)) const body = asRecord(await this.request(url, { method: 'GET' }, signal)) if (body?.code !== 0 || !Array.isArray(body.data)) throw this.invalidResponse() return { - items: body.data.slice(0, 100).map((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) - } - }), + items: filter(body.data.slice(0, pageSize).map((item) => this.catalogItem(item))), 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 { + 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( remoteKnowledgeBaseId: string, query: string, config: ExternalKnowledgeRetrievalConfig, signal?: AbortSignal ): Promise { + config = externalKnowledgeProviderConfigSchema.parse(config) if (config.provider !== this.provider) throw new TypeError('Provider configuration does not match client') const remoteId = remoteKnowledgeBaseId.trim() const question = query.trim() if (!remoteId || !question) throw new TypeError('Remote knowledge base ID and query are required') - if (this.provider === 'dify') { - if (question.length > 250) throw new RangeError('Dify queries cannot exceed 250 characters') + if (this.provider === 'dify' && config.provider === 'dify') { + 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( 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 )) if (!Array.isArray(body?.records)) throw this.invalidResponse() @@ -267,8 +278,10 @@ export class ExternalKnowledgeClient { signal )) const data = asRecord(body?.data) - if (body?.code !== 200 || !Array.isArray(data?.list)) throw this.invalidResponse() - return data.list.slice(0, 20).map((item) => this.fastGptResult(item)) + // The official legacy manual documents data[]; current OpenAPI uses data.list[]. + 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') { @@ -299,12 +312,13 @@ export class ExternalKnowledgeClient { } private async request(url: URL, init: RequestInit, signal?: AbortSignal): Promise { + const timeoutSignal = AbortSignal.timeout(this.timeoutMs) const effectiveSignal = signal - ? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) - : AbortSignal.timeout(this.timeoutMs) - let response: Response + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal try { - response = await this.fetcher(url, { + effectiveSignal.throwIfAborted() + const response = await this.fetcher(url, { ...init, redirect: 'error', signal: effectiveSignal, @@ -314,20 +328,40 @@ export class ExternalKnowledgeClient { ...(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) { - const code = effectiveSignal.aborted ? 'EXTERNAL_KB_TIMEOUT' : 'EXTERNAL_KB_NETWORK' - throw new ExternalKnowledgeError(code, code === 'EXTERNAL_KB_TIMEOUT' ? 'External knowledge request timed out' : 'External knowledge service is unreachable', { cause: error }) + if (effectiveSignal.aborted) { + 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 { const record = asRecord(value) - const id = boundedString(record?.[idKey], 128) + const id = boundedString(record?.[idKey], 512) const name = boundedString(record?.name, 512) 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 { @@ -341,7 +375,7 @@ export class ExternalKnowledgeClient { documentTitle: title, sourceDisplayName: title, snippet, - providerScore: finiteNumber(record?.score), + providerScore: this.resultScore(record?.score), remoteDocumentId: boundedString(document?.id, 128), remoteChunkId: boundedString(segment?.id, 128), location: finiteNumber(segment?.position) === undefined ? undefined : `segment ${segment?.position}` @@ -351,15 +385,26 @@ export class ExternalKnowledgeClient { private fastGptResult(value: unknown): ExternalKnowledgeRetrievalResult { const record = asRecord(value) const question = boundedString(record?.q, 8_000) + if (!question) throw this.invalidResponse() const answer = boundedString(record?.a, 8_000) const snippet = [question, answer].filter(Boolean).join('\n') if (!snippet) throw this.invalidResponse() 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 { documentTitle: title, sourceDisplayName: title, snippet: snippet.slice(0, 8_000), - providerScore: finiteNumber(record?.score), + providerScore: providerScores ? undefined : this.resultScore(record?.score), + ...(providerScores ? { providerScores } : {}), remoteDocumentId: boundedString(record?.sourceId, 128), remoteChunkId: boundedString(record?.id, 128), location: finiteNumber(record?.chunkIndex) === undefined ? undefined : `chunk ${record?.chunkIndex}` @@ -376,13 +421,20 @@ export class ExternalKnowledgeClient { documentTitle: title, sourceDisplayName: title, snippet, - providerScore: finiteNumber(record?.similarity), + providerScore: this.resultScore(record?.similarity), remoteDocumentId: boundedString(record?.document_id, 128), remoteChunkId: boundedString(record?.id, 128), 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 { return new ExternalKnowledgeError('EXTERNAL_KB_INVALID_RESPONSE', 'External knowledge response does not match the supported API') } diff --git a/src/main/knowledge/external/external-knowledge-service.test.ts b/src/main/knowledge/external/external-knowledge-service.test.ts new file mode 100644 index 00000000..3a1759e4 --- /dev/null +++ b/src/main/knowledge/external/external-knowledge-service.test.ts @@ -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(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(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(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(() => new Promise(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(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(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(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(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) +}) diff --git a/src/main/knowledge/external/external-knowledge-service.ts b/src/main/knowledge/external/external-knowledge-service.ts new file mode 100644 index 00000000..c4c0c113 --- /dev/null +++ b/src/main/knowledge/external/external-knowledge-service.ts @@ -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() + 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(id:string,timeout:number,signal:AbortSignal|undefined,operation:(client:ExternalKnowledgeClient,signal:AbortSignal)=>Promise):Promise { + 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 { + 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 { + 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 { + 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}) + } +} diff --git a/src/main/knowledge/external/external-knowledge-store.ts b/src/main/knowledge/external/external-knowledge-store.ts new file mode 100644 index 00000000..4d8c345a --- /dev/null +++ b/src/main/knowledge/external/external-knowledge-store.ts @@ -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 & {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)) + } +} diff --git a/src/main/knowledge/knowledge-database.test.ts b/src/main/knowledge/knowledge-database.test.ts index 2666a7a2..22196198 100644 --- a/src/main/knowledge/knowledge-database.test.ts +++ b/src/main/knowledge/knowledge-database.test.ts @@ -86,7 +86,7 @@ describe('KnowledgeDatabase', () => { const inspection = new DatabaseSync(path) expect( inspection.prepare('PRAGMA user_version').get() - ).toEqual({ user_version: 11 }) + ).toEqual({ user_version: 12 }) expect( inspection .prepare('SELECT version FROM schema_migrations ORDER BY version') @@ -102,7 +102,8 @@ describe('KnowledgeDatabase', () => { { version: 8 }, { version: 9 }, { version: 10 }, - { version: 11 } + { version: 11 }, + { version: 12 } ]) inspection.close() @@ -143,14 +144,14 @@ describe('KnowledgeDatabase', () => { expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1) const inspection = new DatabaseSync(path) expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 11 + user_version: 12 }) inspection.close() }) it.each([ - ['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true], - ['user version', 'PRAGMA user_version = 12', false] + ['migration version', 'INSERT INTO schema_migrations VALUES (13, ?)', true], + ['user version', 'PRAGMA user_version = 13', false] ])('rejects a future %s without downgrading it', async ( _label, statement, @@ -168,18 +169,18 @@ describe('KnowledgeDatabase', () => { const unsupported = new KnowledgeDatabase(path) expect(() => unsupported.initialize()).toThrow( - 'newer than supported version 11' + 'newer than supported version 12' ) const inspection = new DatabaseSync(path) expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ - user_version: hasParameter ? 11 : 12 + user_version: hasParameter ? 12 : 13 }) expect( inspection .prepare('SELECT MAX(version) AS version FROM schema_migrations') .get() - ).toEqual({ version: hasParameter ? 12 : 11 }) + ).toEqual({ version: hasParameter ? 13 : 12 }) inspection.close() }) @@ -319,7 +320,7 @@ describe('KnowledgeDatabase', () => { DROP TABLE chunk_embeddings; DROP TABLE knowledge_tasks; DELETE FROM schema_migrations - WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11); + WHERE version > 1; PRAGMA user_version = 1; `) downgrade.close() @@ -329,7 +330,7 @@ describe('KnowledgeDatabase', () => { upgraded.initialize() const inspection = new DatabaseSync(path) expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ - user_version: 11 + user_version: 12 }) expect( inspection @@ -436,7 +437,7 @@ describe('KnowledgeDatabase', () => { WHERE new.enabled = 1 AND new.role <> 'parent'; END; 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; `) downgrade diff --git a/src/main/knowledge/knowledge-database.ts b/src/main/knowledge/knowledge-database.ts index 4142586c..fcd8e1b7 100644 --- a/src/main/knowledge/knowledge-database.ts +++ b/src/main/knowledge/knowledge-database.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto' +import { ExternalKnowledgeStore } from './external/external-knowledge-store' import { DatabaseSync, type StatementSync } from 'node:sqlite' import { embeddingIndexJobSchema, @@ -80,7 +81,7 @@ import type { VectorSearchOptions } from './types' -const DATABASE_VERSION = 11 +const DATABASE_VERSION = 12 const MAX_ID_LENGTH = 128 const MAX_NAME_LENGTH = 512 const MAX_LOCATION_LENGTH = 8192 @@ -739,6 +740,7 @@ function mapKnowledgeTask(row: Row): KnowledgeTaskItem { } export class KnowledgeDatabase { + externalStore!: ExternalKnowledgeStore private database?: DatabaseSync private readonly taskPrunedLibraries = new Set() private readonly taskPrunePending = new Set() @@ -765,6 +767,7 @@ export class KnowledgeDatabase { this.assertFts5(database) this.migrate(database) this.database = database + this.externalStore = new ExternalKnowledgeStore(database) this.interruptActiveKnowledgeTasks() database .prepare('DELETE FROM embedding_rebuild_staging') @@ -903,11 +906,30 @@ export class KnowledgeDatabase { return row ? mapKnowledgeBase(row) : undefined } + saveExternalBinding( + binding: Omit, + 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( id: string, input: UpdateKnowledgeBaseInput ): KnowledgeBase { 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 = input.name === undefined ? current.name @@ -4587,6 +4609,19 @@ export class KnowledgeDatabase { ) .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('COMMIT') } catch (error) { diff --git a/src/main/knowledge/knowledge-service.test.ts b/src/main/knowledge/knowledge-service.test.ts index 1c942950..3252ed54 100644 --- a/src/main/knowledge/knowledge-service.test.ts +++ b/src/main/knowledge/knowledge-service.test.ts @@ -1113,6 +1113,42 @@ describe('KnowledgeService', () => { ).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 () => { let releaseReranks: (() => void) | undefined let startedReranks = 0 diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index ad7370af..96731b35 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -1,3 +1,5 @@ +import { ExternalKnowledgeService } from './external/external-knowledge-service' +import { ExternalKnowledgeError } from './external/external-knowledge-client' import { cp, lstat, @@ -144,6 +146,8 @@ export type KnowledgeSnapshot = { } export type KnowledgeServiceOptions = { + credentialCipher?: import('../settings-credential-cipher').SettingsCredentialCipher + externalFetcher?: typeof fetch databasePath: string managedRoot: string extractStructured?: ExtractStructured @@ -208,6 +212,7 @@ function embedKnowledgeQuery( } export class KnowledgeService { + readonly external: ExternalKnowledgeService readonly database: KnowledgeDatabase private readonly managedRoot: string private readonly extractStructured?: ExtractStructured @@ -255,6 +260,7 @@ export class KnowledgeService { constructor(options: KnowledgeServiceOptions) { this.database = new KnowledgeDatabase(options.databasePath) + this.external = new ExternalKnowledgeService(this.database, options.credentialCipher, options.externalFetcher) this.managedRoot = resolve(options.managedRoot) this.extractStructured = options.extractStructured this.urlImporter = options.urlImporter ?? new UrlImporter() @@ -293,6 +299,7 @@ export class KnowledgeService { } async dispose(): Promise { + this.external.dispose() this.lifecycleController.abort( new Error('Knowledge service is shutting down') ) @@ -440,6 +447,7 @@ export class KnowledgeService { private async getEmbeddingIndexCoordinator( knowledgeBaseId: string ): Promise { + this.requireLibrary(knowledgeBaseId) const existing = this.embeddingIndexCoordinators.get(knowledgeBaseId) if (existing) { return existing @@ -1234,9 +1242,23 @@ export class KnowledgeService { signal?: AbortSignal, preparedQueryEmbedding?: PreparedQueryEmbedding ): Promise { + signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal + signal.throwIfAborted() 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 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 startedAt = Date.now() const requestedChannels: Array<'fts' | 'cjk' | 'vector' | 'graph'> = [] @@ -1665,32 +1687,64 @@ export class KnowledgeService { response: KnowledgeRetrievalResponse }> > { - const libraries = knowledgeBaseIds.map((id) => - this.requireLibrary(id) + signal = signal ? AbortSignal.any([signal, this.lifecycleController.signal]) : this.lifecycleController.signal + signal.throwIfAborted() + const libraries = [...new Set(knowledgeBaseIds)].map((id) => + this.requireLibrary(id, false) ) 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) : undefined signal?.throwIfAborted() - return Promise.all( - libraries.map(async (library) => ({ - knowledgeBaseId: library.id, - response: await this.retrieve( + const outcomes = await Promise.all( + libraries.map(async (library) => { + try { return { knowledgeBaseId: library.id, response: await this.retrieve( { knowledgeBaseId: library.id, query }, signal, 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 { const input = knowledgeSettingsUpdateInputSchema.parse(rawInput) + this.requireLibrary(input.knowledgeBaseId) return this.database.updateKnowledgeSettings(input) } @@ -1704,6 +1758,7 @@ export class KnowledgeService { rawInput: KnowledgeChunkUpdateInput ): Promise> { const input = knowledgeChunkUpdateInputSchema.parse(rawInput) + this.requireLibrary(input.knowledgeBaseId) return this.withDocumentMutation(input.documentId, async () => { const current = this.database.getChunkForReference( input.knowledgeBaseId, @@ -1733,6 +1788,7 @@ export class KnowledgeService { async deleteChunk(rawInput: KnowledgeChunkDeleteInput): Promise { const input = knowledgeChunkDeleteInputSchema.parse(rawInput) + this.requireLibrary(input.knowledgeBaseId) return this.withDocumentMutation(input.documentId, async () => { const deleted = this.database.deleteChunk(input) if (deleted) { @@ -3806,7 +3862,10 @@ export class KnowledgeService { : 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) if (!library) { throw new Error('知识库不存在') diff --git a/src/main/remote-agent/agent-protocol-client.ts b/src/main/remote-agent/agent-protocol-client.ts index aba5fdd4..8d8be3bc 100644 --- a/src/main/remote-agent/agent-protocol-client.ts +++ b/src/main/remote-agent/agent-protocol-client.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto' import { z } from 'zod' +import { remoteQuestionResponseSchema } from '../../shared/remote-question-contracts' import { workspaceManagementResultSchema } from '../../shared/workspace-management-contracts' import { AGENT_PROTOCOL_LIMITS, @@ -187,6 +188,10 @@ export const AGENT_PROTOCOL_METHODS = { params: remoteOwnedPromptAttachRequestSchema, result: remoteOwnedPromptStartResultSchema }, + 'runtime/respondToQuestion': { + params: remoteQuestionResponseSchema, + result: z.object({}).strict() + }, 'runtime/pagePromptTranscript': { params: remoteSemanticTranscriptPageRequestSchema, result: remoteSemanticTranscriptPageResultSchema diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts index 8d2a9828..47369c1a 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -453,6 +453,10 @@ class ManagedRemoteAcpRuntime implements AgentRuntime { return this.remote.getStatus() } + respondToQuestion(questionId: string, answers: string[][] = []): Promise { + return this.remote.respondToQuestion(questionId, answers) + } + run( request: AgentExecutionRequest, signal: AbortSignal, diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts index 3af10552..b6066540 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts @@ -489,6 +489,17 @@ async function openChannel(fixture = connectionFixture()) { } 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 () => { const fixture = await openChannel() const identity = { diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.ts b/src/main/remote-agent/protocol-remote-runtime-channel.ts index 14535117..58f58a2b 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { remoteQuestionResponseSchema, type RemoteQuestionResponse } from '../../shared/remote-question-contracts' import { AGENT_PROTOCOL_LIMITS, ChannelProtocolError, @@ -679,6 +680,13 @@ export class ProtocolRemoteRuntimeChannel ) } + async respondToQuestion(request: RemoteQuestionResponse): Promise { + const parsed = remoteQuestionResponseSchema.parse(request) + this.#assertOwnedPromptIdentity(parsed) + await requestWithTimeout(this.#state.client, 'runtime/respondToQuestion', parsed, + this.#state.controlTimeoutMs) + } + async pageOwnedPromptTranscript( request: RemoteSemanticTranscriptPageRequest ): Promise { diff --git a/src/preload/index.ts b/src/preload/index.ts index c3054480..39cd8d9c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1609,6 +1609,16 @@ const desktopApi: DesktopApi = { } }, 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) => ipcRenderer.invoke( ipcChannels.knowledgeSnapshot, diff --git a/src/renderer/src/AgentQuestionCard.tsx b/src/renderer/src/AgentQuestionCard.tsx index c9ae8283..1435b162 100644 --- a/src/renderer/src/AgentQuestionCard.tsx +++ b/src/renderer/src/AgentQuestionCard.tsx @@ -10,12 +10,18 @@ type AgentQuestion = Extract type AgentQuestionCardProps = { value: AgentQuestion + id?: string + taskTitle?: string + pendingCount?: number onReject: () => Promise onSubmit: (answers: AgentQuestionAnswer[]) => Promise } export function AgentQuestionCard({ value, + id, + taskTitle, + pendingCount = 1, onReject, onSubmit }: AgentQuestionCardProps): React.JSX.Element { @@ -60,6 +66,8 @@ export function AgentQuestionCard({ return (
{ event.preventDefault() if (complete) { @@ -71,6 +79,8 @@ export function AgentQuestionCard({