diff --git a/UI-DESIGN.md b/UI-DESIGN.md index d71c24cd..aadba5a2 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -177,7 +177,8 @@ `PageHeader` 统一接收以下内容: - `title`:必填,简短名词或任务名称。 -- `description`:可选,一行说明当前页面能做什么,不重复标题。 +- `description`:可选,需要常显的简短上下文,不重复标题或帮助内容。 +- `help`:可选,补充用途和操作说明,通过标题同行的 `InlineHelp` 按需展开,规则见 6.14。 - `scope`:涉及范围的数据页面必填,使用 `ScopeBadge`。 - `primaryAction`:可选,页面唯一主操作。 - `secondaryActions`:可选,最多两个直接显示,其余进入更多菜单。 @@ -402,8 +403,8 @@ 顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。 - 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响; 保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。 -- 共享锚定菜单 `AnchoredMenu` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer - 绘制,避免被 Modal 或滚动容器遮挡。菜单挂在触发按钮所属 Modal 内,通知跟随当前活动 +- 共享锚定菜单 `AnchoredMenu`、同行帮助 `InlineHelp` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer + 绘制,避免被 Modal 或滚动容器遮挡。菜单和帮助挂在触发按钮所属 Modal 内,通知跟随当前活动 Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。 - 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放 浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。 @@ -473,6 +474,20 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开 - 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。 - 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。 +### 6.14 InlineHelp 同行帮助 + +页面、区块和字段的补充用途、格式及操作说明统一收进标题或标签旁的 `InlineHelp` 问号按钮。 +`PageHeader` 与 `SettingsSectionHeader` 通过 `help` 接收这类内容;`description` 保留需要常显的上下文,两处不重复说明。描述缺省或为空时不渲染段落,不保留空行。 +当前范围、状态、字段错误、操作进度、风险、费用和影响用户决策的限制仍就地可见。混合文案先拆分,再将补充说明移入帮助。 + +- 标题或标签与问号使用共享 `inline-help-label` 同行排列,不为帮助另占说明行;字段标签通过 `htmlFor` 关联控件,帮助按钮放在 `label` 外,避免点击帮助触发输入或开关。 +- 悬停或键盘聚焦按钮时展开;指针可移入浮层阅读。点击(含 Enter、Space 激活)固定展开,再次点击关闭;未固定时,指针和焦点均离开后关闭。 +- Escape、点击外部或焦点移到外部关闭帮助。Escape 优先关闭帮助,不同时关闭所属 Modal;展开和关闭不主动转移焦点,不建立焦点陷阱。 +- 按钮提供与标题或标签对应的可访问名称、`aria-expanded`,展开时通过 `aria-controls`、`aria-describedby` 关联 `role="tooltip"` 内容。提供稳定帮助 ID 时,关闭状态保留同 ID 的隐藏说明,展开时由浮层替换,确保字段的 `aria-describedby` 始终有唯一目标。持久标签与必要提示不得依赖浮层。 +- SSH 密码字段常显认证用途和验证成功后加密保存的说明;Main 处理及不写入命令行、日志、普通设置的细节收进同行帮助。应用常驻说明与关闭后果使用独立翻译键,关闭后果保持常显,不按标点拆分翻译文本。 +- 浮层复用 `FloatingPortal`,遵循 6.10 的 Modal 归属规则。宽度最多 `360px`,四周保留 `16px` 视口边距;空间不足时翻转或调整位置,长内容在浮层内滚动,窗口及滚动变化后重新定位。 +- 问号使用共享 `28px × 28px` 点击区域、焦点环与主题令牌。不得另做原生 `title` 长说明或页面专属帮助浮层。 + ## 7. 交互状态 所有可交互组件必须实现: @@ -725,13 +740,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 笔记总览与待办使用一个普通按钮切换,放在页头“新建笔记”按钮左边。按钮显示目标“切换到待办”或“切换到笔记”及对应图标,按内容宽度显示,不拉伸;窄窗口操作组可换行到标题下方,保留控件高度。 - 切换按钮不使用页签语义,切换后焦点回到同一个按钮;内容 section 持续提供当前列表的 `aria-label`。切换沿用草稿保护入口,待办状态仍使用独立的 `SegmentedControl`。 -- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。 +- 文字记录编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套文字记录持久化格式或第三方 Markdown 插件;新建文字记录的操作提示收进类型选择旁的 `InlineHelp`。 - 新记录可选择“文字 / 画布”;已保存画布提供分页阅读和“展开编辑画布”。画布使用 PeopleLib 的 Fabric 分页批注与 Quill 跨页正文,支持纸张模板、增删页、笔迹、高亮、整对象橡皮、选择及移动缩放旋转、浮动文字、图片和 PDF 底版。画布有固定页面边界;已保存记录、待办来源和编辑态默认容纳完整当前页及工具栏,高度随当前页实际显示尺寸变化,纵向由外层笔记流滚动。多页仍属于一条记录,通过页码翻页;窄窗口工具栏换行,纸张仅在自身视口内横向滚动,不撑宽外层页面。 - 画布通过“保存记录 / 保存修改”手动持久化,离开或切换记录类型前沿用未保存草稿确认。撤销/重做作用于当前批注页或 Quill 正文模式,增删页和 PDF 导入不属于全局撤销历史。删除非空批注页或 PDF 页需要在五秒内再次点击确认。 - 画布编辑与只读工具栏均提供缩小、当前百分比、放大和“适应宽度”。默认 100%,范围 25%–300%;加减每次调整 25 个百分点,点击百分比还原 100%。画布区域内 Ctrl/Command + 滚轮连续缩放,普通滚轮和 Shift 横滚保持原有方向与边界传递。适应宽度按实际视口计算,窗口或分栏调整后自动更新;翻页保留固定比例或适应宽度策略。缩放仅影响显示,整页高度与横向滚动范围随之变化,不新增内层纵向滚动,也不改变正文分页、保存内容、导出尺寸或未保存状态。控件沿用画布的主题、焦点环与提示,窄屏整组换行,适应宽度通过 `aria-pressed` 和选中样式表达状态。 - “导入 PDF”保留页面并提取原生文字供搜索与 AI 使用,扫描页不自动 OCR。“导出 PDF”将底版、正文和批注逐页合成为栅格 PDF,不保留可搜索文字层;当前 UI 无 PNG 下载按钮,页面 PNG 捕获仅作为程序接口提供。 - 画布新建与编辑态不显示常驻分析提示。操作放在编辑区右上角:新建与类型选择同排,顺序为“分析画布草稿、保存记录”;编辑放在记录头部,顺序为“分析画布草稿、取消、保存修改”。保存为最右侧主按钮,窄屏允许换行;底部不重复操作,新建不增加取消按钮。`immediate` 仍通过按钮手动分析,不随笔画自动请求;`after-save-auto` 在手动保存后分析。设置读取或页面捕获失败不阻断保存,保存成功后通过应用通知说明自动分析失败,可稍后重新分析;模型分析失败也不回滚已保存内容。 -- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题旁的问号图标通过现有 `title` 提示展示说明,不常驻整段文字;下拉框与帮助图标均通过 `aria-describedby` 关联屏幕阅读器说明。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。 +- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。 - 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。 - 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。 - 默认进入笔记总览,不自动打开第一篇笔记。笔记以自适应卡片网格平铺,卡片显示标题、最多两行内容预览、记录数和本地化更新时间。点击笔记进入详情,详情不保留内部左列表;“返回总览”恢复搜索、滚动位置和入口焦点。新建入口在笔记总览与待办页可见,创建成功后进入笔记详情。 @@ -777,7 +792,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即 更新,保存失败时保留最后确认状态。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目(技术预览)”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 -- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 +- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 - “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 538c459f..cd2f9e2c 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,25 @@ # 工作栏实现与验证进度 +## 2026-09-19 问答位置持久化 + +- Main 在收到问题时保存消息位置块,而不是等回答成功才追加;前台及重启恢复的实时待答 + 快照、带来源的语义事件投影共用同一去重函数。答案写入不移动位置块,旧消息不补造历史 + 顺序;规则见[Runtime 交互边界](./runtime-interactions.md#运行与失败)。未改变 schema。 +- `npm exec -- vitest run src/main/assistant/assistant-database.test.ts src/main/ipc.test.ts` + 通过 227 项,覆盖真实 SQLite 的问题/正文/工具交错、重复和重放、回答/跳过、完成及 + 重新打开,以及三个设置重载入口的并行远程问题恢复。 + `npm exec -- tsc --noEmit -p tsconfig.node.json` 通过。 +- 共享 Linux x64 Host 使用已保存凭据与固定 Host Key,经 LAN 路由运行当前源码 Agent、 + Desktop Runtime 适配器和 Main SQLite 投影,使用真实 OpenCode `1.18.29`。首轮暴露实时 + 问题没有语义来源的实际分支,使用 1 次真实文本模型请求后停止;补齐 Main 接收入口后, + 最终一轮 2 次请求,验证回答前位置块已经落库,回答后为 `tool → question → text`, + 完成重开后消息完全一致。合计真实文本模型调用 **3 次**,每次最多 1024 输出 token, + 每轮有限调用预算与 120 秒请求超时;图片模型调用 **0 次**。 +- 临时复验脚本为既有批准临时目录中的 `question-order-host-build.cjs`、 + `question-order-host-run.cjs`,最终日志为 `question-order-host-live.log`。 + 两轮均停止测试 daemon,所属进程剩余 0,移除本轮专属 Host 目录及上传文件。 + 该记录只覆盖 Main/Agent 路径,不代替 Renderer 视觉验收或最终全量验证。 + ## 2026-09-19 远程 CN 完整桌面验证 默认联合开发包已包含 OC/CN,并通过桌面导入、验签、传输至共享 Linux x64、安装及 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 48612042..a381b05f 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -29,6 +29,10 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包 - 回答或跳过成功后,会话保留只读的“问题与回答”,显示原问题及对应的选项答案、 自定义文本或“已跳过”。同一任务的多轮问答逐次保留,随本地会话保存,重新打开后仍可查看。 提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。 +- 新消息在提问到达时,将只含 `questionId` 的问题位置块插入当时的消息块序列; + 待答和回答后的卡片使用同一位置,后续正文、工具输出不再排到问题前面。重复问题不追加 + 或移动位置块。位置块不保存待答表单;旧会话没有位置块时保留原有问答展示,不推测或 + 回填历史顺序。该变更复用既有消息元数据,不升级数据库 schema。 - 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时 不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。 - 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个 @@ -38,6 +42,8 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包 - 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答 列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。 回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。 + Main 在前台与恢复请求收到实时问题时立即保存位置块;实时待答快照没有语义事件来源, + 不伪造来源或追加 transcript。带来源的提问事件仍通过既有事务投影并按问题 ID 去重。 Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用 新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止, 不使用替换后的模型汇总旧结果。详细实现见 diff --git a/docs/features/knowledge-base/progress.md b/docs/features/knowledge-base/progress.md index 757b4427..99d5a9c4 100644 --- a/docs/features/knowledge-base/progress.md +++ b/docs/features/knowledge-base/progress.md @@ -16,6 +16,14 @@ ## 验证证据 +### 2026-09-20 清理孤立的内存图谱搜索 + +- 删除仅剩测试调用的 `searchGraph`、专属评分/遍历辅助函数、搜索上限及测试 fixture。 + 实际检索仍通过 `KnowledgeDatabase.hybridSearchWithDiagnostics` 与 `graphSearchScored`; + 图谱抽取、归一化、合并、数据库结构和已发布迁移均保持不变。 +- `graph-extractor.test.ts` 与 `knowledge-database.test.ts` 合计 55 项通过,保留合并时 + 丢弃失去端点关系的回归;没有调用外部模型。 + ### 2026-09-13 知识库布局与自动名称修复 - 创建类型的共享分段控件被长表单压缩,修复共享不收缩规则,不添加页面专属高度。 diff --git a/docs/features/magic-notes/progress.md b/docs/features/magic-notes/progress.md index 58d5b309..172453ec 100644 --- a/docs/features/magic-notes/progress.md +++ b/docs/features/magic-notes/progress.md @@ -1,5 +1,40 @@ # Magic Notes Progress +## 2026-09-20: Shared Inline Help + +The canvas page-count question mark now uses `InlineHelp`, superseding the native +`title` mechanism recorded below. The overview description, comment-direction +help and new text-entry hints also use the shared component. Interaction rules +are maintained in [the UI design system](../../../UI-DESIGN.md#614-inlinehelp-同行帮助). + +The final verification ran `npm test -- --reporter=default --reporter=json +--outputFile.json=`: **412 files passed, 9 skipped; 4,858 tests +passed, 67 skipped, no failures**, in 1,016.17 seconds. The JSON report confirms +`success: true`. `npm run typecheck` and `npm run lint` passed. The focused help, +application, SSH, Skills and MCP run also passed **6 files / 54 tests**. SSH password +help now keeps authentication and encrypted-storage consequences visible; Skills +and MCP headers no longer retain an empty description paragraph. Pin help and +disable consequences use separate Chinese and English translation keys. + +`tests/inline-help.electron.test.ts` renders production `ApplicationCenter` / +`ApplicationSettingsView`, `SettingsPanel`, retrieval and activity components with +fixture data. Native Electron input verified hover, focus, click pinning, Escape +isolation, outside dismissal and Modal ownership. Chromium AX checks confirmed +unique description IDs and accessible text before, during and after expansion. +Light/dark checks cover 1280x800 and 640x480 consumer views, plus 1000x700 and +360x320 shared-component views. Computed styles and captured screenshots confirm +that canvas-count and pin help match model settings: 28px buttons, 14px icons, +4px label gaps, zero center offset, identical button colors/margins and tooltip +typography/padding/borders in each theme. No CSS difference was reproduced, so +this pass adds comparison assertions without changing styles. + +The focused run retained 28 screenshots and `evidence.json` under the temporary +`inline-help-final` artifact directory selected by `GOODBUDDY_HELP_ARTIFACTS`. +This validates real Electron rendering with fixture services, not a packaged +full App or live provider. Help actions triggered zero consumer operations and +zero model calls. The full suite also passed both production canvas-analysis +Electron cases with substituted model output; live-provider checks remain opt-in. + ## 2026-09-19: Compact Page Count Setting - The page-count select now uses the existing `.field` settings control styling. diff --git a/docs/features/model-connections/ui-design.md b/docs/features/model-connections/ui-design.md index 56fcb540..54068132 100644 --- a/docs/features/model-connections/ui-design.md +++ b/docs/features/model-connections/ui-design.md @@ -24,6 +24,8 @@ 该值。请求体说明明确列出直连、Continue 和受管远程 OpenCode 支持,并说明本地 OpenCode 与 DeepSeek Harness 不使用该值。 +两项说明使用标签同行的 `InlineHelp`,交互遵循[同行帮助规范](../../../UI-DESIGN.md#614-inlinehelp-同行帮助)。下列警告及字段错误保持常显。 + 同一区域还必须说明: - Runtime、协议和认证生成的字段优先。 @@ -32,7 +34,7 @@ OpenCode 与 DeepSeek Harness 不使用该值。 ## 无障碍与键盘 -- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。 +- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。帮助关闭时保留隐藏说明,展开时替换为同 ID 的浮层,避免关联丢失或 ID 重复。 - 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。 - 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。 - 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。 @@ -49,7 +51,7 @@ Runtime 入口恢复跟随。该展示规则不改变模型解析、远程 Agent DeepSeek Harness 的 `platform` 选项命名为“管理员环境优先,回退 GoodBuddy 兼容连接”, 不能沿用 OpenCode/Continue 的“Runtime 自有配置”标签。来源规则以 [Harness 模型配置](../deepseek-harness/technical-design.md#121-配置来源)为准。 -帮助文字与选择器通过 `aria-describedby` 关联,并明确此选项不加载用户 profile 或 +帮助文字收进标签同行的 `InlineHelp`,与选择器通过 `aria-describedby` 持续关联,并明确此选项不加载用户 profile 或 配置文件;无兼容 GoodBuddy 连接时仍可选择管理员环境来源。 概览使用 Main 提供的[平台来源投影](./technical-design.md#9-harness-平台来源公开投影), diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index e5d61f69..df0eb20f 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -359,6 +359,10 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创 未发布进程复用源码补齐 Desktop 重启后的多任务并行附加、实时待答快照及 Main 答案 持久化,详见[恢复与终态投影](../assistant-workbar/runtime-process-reuse-technical-design.md#63-恢复与终态投影); 不把该待答投影当作 durable transcript 或旧版本已经支持的行为。 + Desktop Main 接收实时问题时保存其消息位置,回答后不移动到后续输出之后;实时待答 + 快照不伪造语义来源。位置去重、旧会话边界见 + [Runtime 交互边界](../assistant-workbar/runtime-interactions.md#运行与失败), + 当前源码的真实 Host 证据见[问答位置持久化](../assistant-workbar/progress.md#2026-09-19-问答位置持久化)。 - ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的 `` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属 `toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。 @@ -851,6 +855,23 @@ goodbuddy-agent diagnostics --installation-id 信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和 payload 仍按原始签名字节及 SHA-256 验证。 +## 2026-09-20 冗余代码清理验证 + +- 删除没有生产调用的结构化命令 runner、两个进程身份辅助函数、占位 peer provider、 + 平台查询辅助函数和五个旧 Runtime 请求构造/摘要辅助函数。保留实际 CLI、进程检查器、 + 请求 schema、原生 peer 校验及包签名验证,不改变 Ask/Execute 或协议行为。 +- 清理后的当前源码 Agent、Main attach transport 和 protocol client 在共享 Linux x64 + Host 上通过固定 Host Key 连接,完成启动、状态/能力读取、只读工作区打开及真实文件读取, + 断开后再次连接并重复读取。验证没有使用模拟 Agent 或旧安装包的 Agent 实现。 +- 本轮测试进程剩余 0;专属 Host 目录、上传文件及本地隔离 profile 已删除。 + 文本和图片模型请求均为 **0 次**。这是未使用代码删除后的启动/连接验证,不是新增的 + Runtime 模型调用、安装更新或其他架构验收。 +- 临时证据为 `redundancy-cleanup-host-{build,run}.cjs` 与 + `redundancy-cleanup-host-live.log`;定向回归 230 项通过,typecheck、lint 通过。 +- 同期全量 `npm test` 为 4,844 通过、67 跳过、11 失败;失败来自并行修改中的五个 + Renderer 帮助提示/焦点测试套件。更新后的五个套件连同更新设置大小展示回归共 64 项 + 定向复跑全部通过;未把这次复跑记为又一轮全量通过。 + ## 发布前完整回归(不能替代开发验证) 以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次 diff --git a/src/agent-daemon/daemon.ts b/src/agent-daemon/daemon.ts index f129f9ca..aa9ff055 100644 --- a/src/agent-daemon/daemon.ts +++ b/src/agent-daemon/daemon.ts @@ -465,14 +465,3 @@ function mergeProtocolMethods( } return { ...workspaceMethods, ...runtimeMethods } } - -export class UnsupportedPeerIdentityProvider - implements UnixPeerIdentityProvider -{ - async getPeerIdentity(): Promise { - throw new AgentUnsupportedError( - 'A certified SO_PEERCRED helper is unavailable', - 'peer-identity-unavailable' - ) - } -} diff --git a/src/agent-daemon/index.ts b/src/agent-daemon/index.ts index fab19d90..808d0746 100644 --- a/src/agent-daemon/index.ts +++ b/src/agent-daemon/index.ts @@ -29,7 +29,6 @@ export * from './runtime-acp-backend' export * from './runtime-owner-registry' export * from './runtime-process-identity' export * from './semantic-prompt-store' -export * from './structured-command-runner' export * from './workspace-git-service' export * from './workspace-path-access' export * from './workspace-protocol-methods' diff --git a/src/agent-daemon/linux-process-identity.ts b/src/agent-daemon/linux-process-identity.ts index ebafcd59..d7de706e 100644 --- a/src/agent-daemon/linux-process-identity.ts +++ b/src/agent-daemon/linux-process-identity.ts @@ -1,5 +1,4 @@ import { - lstatSync, readFileSync, readlinkSync, realpathSync @@ -115,25 +114,6 @@ export class LinuxProcessInspector { } } -export function currentLinuxProcessIdentity( - options: LinuxProcessInspectorOptions = {} -): LinuxProcessIdentity { - const identity = new LinuxProcessInspector(options).inspect(process.pid) - if (identity === undefined) { - throw new Error('Current Linux process identity is unavailable') - } - return identity -} - -export function canonicalExecutablePath(path: string): string { - const normalized = assertAbsoluteManagedPath(resolve(path)) - const stat = lstatSync(normalized) - if (stat.isSymbolicLink() || !stat.isFile()) { - throw new Error('Agent executable must be a regular file') - } - return assertAbsoluteManagedPath(realpathSync(normalized)) -} - function parseStarttime(contents: string): string { const close = contents.lastIndexOf(')') if (close < 2 || contents[close + 1] !== ' ') { diff --git a/src/agent-daemon/structured-command-runner.ts b/src/agent-daemon/structured-command-runner.ts deleted file mode 100644 index 5ce8e378..00000000 --- a/src/agent-daemon/structured-command-runner.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { spawn } from 'node:child_process' - -export type SpawnResult = { - exitCode: number - stdout: string - stderr: string - signal?: NodeJS.Signals - terminationReason?: 'timeout' | 'aborted' -} - -export type StructuredCommandOptions = { - env?: Readonly - signal?: AbortSignal - timeoutMs?: number - terminationGraceMs?: number -} - -export interface StructuredCommandRunner { - run( - executable: string, - args: readonly string[], - options?: StructuredCommandOptions - ): Promise -} - -const DEFAULT_COMMAND_TIMEOUT_MS = 30_000 -const MAXIMUM_COMMAND_TIMEOUT_MS = 120_000 -const DEFAULT_TERMINATION_GRACE_MS = 1_000 -const MAXIMUM_TERMINATION_GRACE_MS = 10_000 - -export class NodeStructuredCommandRunner implements StructuredCommandRunner { - async run( - executable: string, - args: readonly string[], - options: StructuredCommandOptions = {} - ): Promise { - const timeoutMs = boundedDuration( - options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, - 'Command timeout', - MAXIMUM_COMMAND_TIMEOUT_MS - ) - const terminationGraceMs = boundedDuration( - options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS, - 'Command termination grace', - MAXIMUM_TERMINATION_GRACE_MS - ) - if (options.signal?.aborted === true) { - return { - exitCode: -1, - stdout: '', - stderr: '', - terminationReason: 'aborted' - } - } - return await new Promise((resolveResult) => { - const child = spawn(executable, [...args], { - shell: false, - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - ...(options.env === undefined ? {} : { env: options.env }) - }) - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - let stdoutBytes = 0 - let stderrBytes = 0 - const maximumBytes = 64 * 1024 - let settled = false - let terminationReason: SpawnResult['terminationReason'] - const timeout = setTimeout(() => terminate('timeout'), timeoutMs) - let escalation: NodeJS.Timeout | undefined - let closeDeadline: NodeJS.Timeout | undefined - let spawnError: Error | undefined - child.stdout.on('data', (chunk: Buffer) => { - if (stdoutBytes < maximumBytes) { - const bounded = chunk.subarray(0, maximumBytes - stdoutBytes) - stdout.push(bounded) - stdoutBytes += bounded.byteLength - } - }) - child.stderr.on('data', (chunk: Buffer) => { - if (stderrBytes < maximumBytes) { - const bounded = chunk.subarray(0, maximumBytes - stderrBytes) - stderr.push(bounded) - stderrBytes += bounded.byteLength - } - }) - const finish = ( - exitCode: number | null, - signal: NodeJS.Signals | null - ): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (escalation !== undefined) { - clearTimeout(escalation) - } - if (closeDeadline !== undefined) { - clearTimeout(closeDeadline) - } - options.signal?.removeEventListener('abort', abort) - const capturedStderr = Buffer.concat(stderr).toString('utf8') - resolveResult({ - exitCode: exitCode ?? -1, - stdout: Buffer.concat(stdout).toString('utf8'), - stderr: - capturedStderr || - (spawnError === undefined ? '' : spawnError.message), - ...(signal === null ? {} : { signal }), - ...(terminationReason === undefined ? {} : { terminationReason }) - }) - } - const terminate = ( - reason: NonNullable - ): void => { - if (settled || terminationReason !== undefined) { - return - } - terminationReason = reason - if (child.exitCode !== null || child.signalCode !== null) { - return - } - child.kill('SIGTERM') - escalation = setTimeout(() => { - if ( - settled || - child.exitCode !== null || - child.signalCode !== null - ) { - return - } - child.kill('SIGKILL') - closeDeadline = setTimeout( - () => finish(child.exitCode, child.signalCode), - terminationGraceMs - ) - }, terminationGraceMs) - } - const abort = (): void => terminate('aborted') - child.once('error', (error) => { - spawnError = error - }) - child.once('close', finish) - options.signal?.addEventListener('abort', abort, { once: true }) - if (options.signal?.aborted === true) { - abort() - } - }) - } -} - -export function formatSpawnFailure( - command: string, - result: SpawnResult -): string { - const status = - result.terminationReason === 'timeout' - ? 'timed out' - : result.terminationReason === 'aborted' - ? 'was cancelled' - : result.signal !== undefined - ? `terminated by ${result.signal}` - : `exited with status ${result.exitCode}` - return `${command} ${status}: ${boundedError(result.stderr)}` -} - -function boundedDuration( - value: number, - name: string, - maximum: number -): number { - if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { - throw new Error(`${name} must be between 1 and ${maximum} milliseconds`) - } - return value -} - -function boundedError(error: string): string { - const normalized = error.replaceAll(/\s+/gu, ' ').trim() - return normalized.slice(0, 1000) || 'unknown error' -} diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 2682a099..ef7744b9 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -4131,6 +4131,73 @@ describe('AssistantDatabase', () => { database.close() }) + it.each([false, true])('keeps remote questions in arrival order through replay and reopen (legacy=%s)', async legacy => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-question-order-')) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize('C:\\Workspace') + try { + const project = database.createSshProject(validatedSshProjectWrite()) + const conversationId = randomUUID() + const taskId = randomUUID() + const assistantMessageId = randomUUID() + database.saveLocalConversations([{ + header: { id: conversationId, projectId: project.id, title: 'Question order', updatedAt: Date.now() }, + messages: [] + }]) + database.createTask({ + id: taskId, projectId: project.id, conversationId, title: 'Question order', + instructions: 'Ask then acknowledge', workMode: 'execute', + remoteRecovery: { recoverable: true, currentUserMessageId: randomUUID(), currentAssistantMessageId: assistantMessageId } + }) + if (legacy) { + const raw = new DatabaseSync(databasePath) + try { + raw.prepare("UPDATE messages SET metadata_json = json_remove(metadata_json, '$.blocks') WHERE id = ?").run(assistantMessageId) + } finally { raw.close() } + } + const append = (sequence: string, event: Parameters[0]['event']) => + database.appendRemoteConversationTaskEventOnce({ + taskId, conversationId, assistantMessageId, bindingId: 'question-binding', + operationId: 'question-operation', semanticSequence: sequence, eventIndex: 0, event + }) + const question = { + requestId: taskId, type: 'question' as const, questionId: 'first-question', + questions: [{ header: 'Choice', question: 'Choose one', options: [{ label: 'Yes', description: 'Continue' }], multiple: false, custom: false }] + } + expect(append('1', question)).toBe(true) + const initialBlocks = database.getConversation(conversationId).messages[1]!.blocks + expect(initialBlocks).toEqual(legacy ? undefined : [expect.objectContaining({ type: 'question', questionId: question.questionId })]) + expect(append('1', question)).toBe(false) + expect(append('2', { requestId: taskId, type: 'text', delta: 'After first. ' })).toBe(true) + expect(append('3', { requestId: taskId, type: 'tool', callId: 'read-1', name: 'read', state: 'running', summary: 'Read fixture' })).toBe(true) + expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true) + expect(database.recordRemoteTaskQuestionArrival(taskId, 'second-question')).toBe(true) + expect(append('4', { ...question, questionId: 'second-question' })).toBe(true) + // A fresh semantic envelope for the same live question must not move its marker. + expect(append('5', question)).toBe(true) + expect(append('6', { requestId: taskId, type: 'text', delta: 'After second.' })).toBe(true) + const answer = { questionId: question.questionId, skipped: false, questions: question.questions.map(item => ({ ...item, answer: ['Yes'] })) } + expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true) + expect(database.recordRemoteTaskQuestionAnswer(taskId, answer)).toBe(true) + expect(database.recordRemoteTaskQuestionAnswer(taskId, { questionId: 'second-question', skipped: true, questions: question.questions })).toBe(true) + const beforeTerminal = database.getConversation(conversationId).messages[1]! + expect(beforeTerminal.blocks?.map(block => block.type)).toEqual(legacy ? undefined : ['question', 'text', 'tool', 'question', 'text']) + if (!legacy) expect(beforeTerminal.blocks?.[0]).toEqual(initialBlocks?.[0]) + expect(append('7', { requestId: taskId, type: 'done' })).toBe(true) + const completed = database.getConversation(conversationId).messages[1]! + expect(completed.answeredQuestions).toHaveLength(2) + expect(completed.content).toBe('After first. After second.') + expect(completed.state).toBe('complete') + expect(completed.blocks?.filter(block => block.type === 'question')).toEqual(beforeTerminal.blocks?.filter(block => block.type === 'question')) + database.close() + database.initialize('C:\\Workspace') + expect(append('1', question)).toBe(false) + expect(database.getConversation(conversationId).messages[1]).toEqual(completed) + } finally { database.close() } + }) + it('rolls back a recovered event when its message update fails', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-event-atomic-') diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 775db82d..ed59c555 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text' import { knowledgeReferenceKey } from '../../shared/knowledge-reference' +import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks' import { DatabaseSync } from 'node:sqlite' import { statSync } from 'node:fs' import { MagicNoteStorage } from '../magic-notes/magic-note-storage' @@ -1383,6 +1384,11 @@ function reduceRecoveredAgentEvent( blocks, status: undefined } + } else if (event.type === 'question') { + next = { + ...message, + blocks: appendConversationQuestionBlock(message.blocks, event.questionId) + } } else if (event.type === 'checklist') { next = { ...message, runtimeChecklist: event.checklist } } else if (event.type === 'status') { @@ -5099,6 +5105,27 @@ export class AssistantDatabase { }) } + recordRemoteTaskQuestionArrival(taskIdInput: string, questionId: string): boolean { + const taskId = assistantIdSchema.parse(taskIdInput) + const database = this.requireDatabase() + const row = database.prepare( + `SELECT m.id, m.metadata_json + FROM tasks t JOIN messages m + ON m.id = t.current_assistant_message_id + AND m.conversation_id = t.conversation_id AND m.request_id = t.id + WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'` + ).get(taskId) as { id: string; metadata_json: string } | undefined + if (!row) return false + const metadata = JSON.parse(row.metadata_json) as MessageMetadata + const blocks = appendConversationQuestionBlock(metadata.blocks, questionId) + if (blocks !== metadata.blocks) { + metadata.blocks = blocks + database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?') + .run(JSON.stringify(metadata), row.id) + } + return true + } + recordRemoteTaskQuestionAnswer( taskIdInput: string, answerInput: ConversationAnsweredQuestion diff --git a/src/main/channels/channel-env.ts b/src/main/channels/channel-env.ts index 05604e65..6a9c4963 100644 --- a/src/main/channels/channel-env.ts +++ b/src/main/channels/channel-env.ts @@ -1,4 +1,3 @@ -import type { ChannelInboundText } from '../../shared/channel-contracts' import type { ChannelExecutor } from './channel-driver' import { ChannelService } from './channel-service' import { @@ -222,9 +221,3 @@ export function startEnvironmentChannels( }) return services } - -export function isReadOnlyChannelMessage( - message: ChannelInboundText -): boolean { - return message.workMode === 'ask' -} diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index df47bd74..35d31051 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1361,8 +1361,6 @@ vi.mock('./assistant/heartbeat-service', () => ({ vi.mock('./agent/create-runtime', () => runtimeFactoryMocks) vi.mock('./channels/channel-env', () => ({ - isReadOnlyChannelMessage: (message: { workMode: string }) => - message.workMode === 'ask', startEnvironmentChannels: vi.fn( (options: { executor: typeof channelMocks.executor }) => { channelMocks.executor = options.executor @@ -5143,6 +5141,7 @@ describe('registerIpcHandlers agent terminal state', () => { hasRemoteResponseTextAfterToolFailure: vi.fn(() => false), getRemoteTaskActivityStates: vi.fn(() => ({ tools: new Map(), subagents: new Map() })), endRecoverableRemoteTask: vi.fn(), + recordRemoteTaskQuestionArrival: vi.fn(), recordRemoteTaskQuestionAnswer: vi.fn(), getTask: vi.fn(), updateTaskStatus: vi.fn(), @@ -7013,6 +7012,7 @@ describe('registerIpcHandlers agent terminal state', () => { harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database)) harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database)) harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database)) + harness.assistantDatabase.recordRemoteTaskQuestionArrival.mockImplementation(database.recordRemoteTaskQuestionArrival.bind(database)) harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation( (...args) => database.recordRemoteTaskQuestionAnswer(...args) ) @@ -7059,6 +7059,7 @@ describe('registerIpcHandlers agent terminal state', () => { expect(reopened.getTask(task.taskId).status).toBe('completed') expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({ id: task.currentAssistantMessageId, state: 'complete', + blocks: [expect.objectContaining({ type: 'question', questionId: task.taskId })], answeredQuestions: [{ questionId: task.taskId, skipped: index === 0, questions: [{ diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 54a786c4..729f7cb2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2036,6 +2036,7 @@ export function registerIpcHandlers( )) { if (rawEvent.type === 'question' || rawEvent.type === 'question-resolved') { if (rawEvent.type === 'question') { + assistantDatabase.recordRemoteTaskQuestionArrival(task.taskId, rawEvent.questionId) pendingAgentQuestions.set(rawEvent.questionId, { requestId: task.taskId, runtime: recoveredRuntime, question: rawEvent }) @@ -4798,6 +4799,7 @@ export function registerIpcHandlers( toolStates.delete(publicEvent.runtimeCallId) } if (publicEvent.type === 'question') { + assistantDatabase.recordRemoteTaskQuestionArrival(request.requestId, publicEvent.questionId) pendingAgentQuestions.set(publicEvent.questionId, { requestId: request.requestId, runtime: selectedRuntime, diff --git a/src/main/knowledge/graph-extractor.test.ts b/src/main/knowledge/graph-extractor.test.ts index 2078bb7b..0bba636d 100644 --- a/src/main/knowledge/graph-extractor.test.ts +++ b/src/main/knowledge/graph-extractor.test.ts @@ -7,7 +7,6 @@ import { extractKnowledgeGraph, mergeKnowledgeGraphs, normalizeEntityAlias, - searchGraph, validateModelGraph, type GraphChunk, type KnowledgeGraph @@ -867,9 +866,9 @@ describe('extraction strategies', () => { }) }) -describe('graph merge and search', () => { +describe('graph merge', () => { const evidence = { - chunkId: 'search', + chunkId: 'merge', quote: 'evidence', start: 0, end: 8, @@ -883,27 +882,6 @@ describe('graph merge and search', () => { name: 'API Gateway', type: 'service', aliases: ['gateway'], - evidence: [{ ...evidence, confidence: 0.9 }] - }, - { - id: 'users', - name: 'User Service', - type: 'service', - aliases: [], - evidence: [evidence] - }, - { - id: 'database', - name: 'User Database', - type: 'database', - aliases: [], - evidence: [{ ...evidence, confidence: 0.6 }] - }, - { - id: 'unrelated', - name: 'Billing', - type: 'service', - aliases: [], evidence: [evidence] } ], @@ -913,77 +891,11 @@ describe('graph merge and search', () => { sourceId: 'api', targetId: 'users', type: 'calls', - evidence: [{ ...evidence, confidence: 0.95 }] - }, - { - id: 'users-db', - sourceId: 'users', - targetId: 'database', - type: 'uses', - evidence: [{ ...evidence, confidence: 0.8 }] - }, - { - id: 'orphan', - sourceId: 'api', - targetId: 'missing', - type: 'calls', evidence: [evidence] } ] } - it('ranks exact/alias matches, traverses adjacency, and returns a bounded subgraph', () => { - const result = searchGraph(graph, 'gateway', { - maximumEntities: 2, - maximumRelations: 1, - maximumDepth: 2 - }) - - expect(result.matchedEntityIds[0]).toBe('api') - expect(result.entities.map((entity) => entity.id)).toEqual(['api', 'users']) - expect(result.relations.map((relation) => relation.id)).toEqual([ - 'api-users' - ]) - expect(searchGraph(graph, 'not found')).toEqual({ - entities: [], - relations: [], - matchedEntityIds: [] - }) - }) - - it('never exceeds global search limits even when callers request more', () => { - const entities = Array.from( - { length: GRAPH_LIMITS.maximumSearchEntities + 10 }, - (_, index) => ({ - id: `node-${index}`, - name: `node ${index}`, - type: 'node', - aliases: [], - evidence: [evidence] - }) - ) - const largeGraph: KnowledgeGraph = { - entities, - relations: entities.slice(1).map((entity, index) => ({ - id: `edge-${index}`, - sourceId: entities[0]?.id ?? '', - targetId: entity.id, - type: 'links', - evidence: [evidence] - })) - } - const result = searchGraph(largeGraph, 'node', { - maximumEntities: 10_000, - maximumRelations: 10_000 - }) - expect(result.entities.length).toBeLessThanOrEqual( - GRAPH_LIMITS.maximumSearchEntities - ) - expect(result.relations.length).toBeLessThanOrEqual( - GRAPH_LIMITS.maximumSearchRelations - ) - }) - it('discards relations whose endpoints disappear during merge', () => { const merged = mergeKnowledgeGraphs( { entities: [graph.entities[0]!], relations: [graph.relations[0]!] }, diff --git a/src/main/knowledge/graph-extractor.ts b/src/main/knowledge/graph-extractor.ts index e67f4820..e4af6759 100644 --- a/src/main/knowledge/graph-extractor.ts +++ b/src/main/knowledge/graph-extractor.ts @@ -21,8 +21,6 @@ export const GRAPH_LIMITS = { maximumRelations: 400, maximumFieldLength: 120, maximumQuoteLength: 500, - maximumSearchEntities: 50, - maximumSearchRelations: 100, maximumWarnings: 20, maximumWarningLength: 240 } as const @@ -91,16 +89,6 @@ export interface ExtractKnowledgeGraphOptions { modelRetryBackoffMilliseconds?: number } -export interface GraphSearchOptions { - maximumEntities?: number - maximumRelations?: number - maximumDepth?: number -} - -export interface GraphSearchResult extends KnowledgeGraph { - matchedEntityIds: string[] -} - const emptyGraph = (): KnowledgeGraph => ({ entities: [], relations: [] }) const modelEvidenceSchema = z @@ -1003,152 +991,3 @@ export async function extractKnowledgeGraph( warnings: [...context.warnings].slice(0, GRAPH_LIMITS.maximumWarnings) } } - -function bestEvidenceConfidence(evidence: readonly GraphEvidence[]): number { - return evidence.reduce( - (maximum, item) => Math.max(maximum, item.confidence), - 0 - ) -} - -function entityMatchScore(entity: GraphEntity, query: string): number { - const key = normalizeEntityAlias(entity.name) - const type = normalizeEntityAlias(entity.type) - const aliases = entity.aliases.map(normalizeEntityAlias) - if (key === query || aliases.includes(query)) { - return 100 - } - if (key.startsWith(query) || aliases.some((alias) => alias.startsWith(query))) { - return 80 - } - if (key.includes(query) || aliases.some((alias) => alias.includes(query))) { - return 60 - } - if (type.includes(query)) { - return 30 - } - return 0 -} - -export function searchGraph( - graph: KnowledgeGraph, - query: string, - options: GraphSearchOptions = {} -): GraphSearchResult { - const normalizedQuery = normalizeEntityAlias(query) - if (!normalizedQuery) { - return { ...emptyGraph(), matchedEntityIds: [] } - } - const maximumEntities = Math.max( - 1, - Math.min( - options.maximumEntities ?? 20, - GRAPH_LIMITS.maximumSearchEntities - ) - ) - const maximumRelations = Math.max( - 0, - Math.min( - options.maximumRelations ?? 40, - GRAPH_LIMITS.maximumSearchRelations - ) - ) - const maximumDepth = Math.max(0, Math.min(options.maximumDepth ?? 1, 3)) - const entitiesById = new Map( - graph.entities - .slice(0, GRAPH_LIMITS.maximumEntities) - .map((entity) => [entity.id, entity]) - ) - const validRelations = graph.relations - .slice(0, GRAPH_LIMITS.maximumRelations) - .filter( - (relation) => - entitiesById.has(relation.sourceId) && - entitiesById.has(relation.targetId) - ) - const scored = [...entitiesById.values()] - .map((entity) => ({ - entity, - score: entityMatchScore(entity, normalizedQuery) - })) - .filter((item) => item.score > 0) - .sort( - (left, right) => - right.score - left.score || - bestEvidenceConfidence(right.entity.evidence) - - bestEvidenceConfidence(left.entity.evidence) || - left.entity.name.localeCompare(right.entity.name) - ) - const matchedEntityIds = scored - .slice(0, maximumEntities) - .map((item) => item.entity.id) - const selected = new Set(matchedEntityIds) - let frontier = new Set(matchedEntityIds) - for ( - let depth = 0; - depth < maximumDepth && selected.size < maximumEntities; - depth += 1 - ) { - const candidates = new Map() - for (const relation of validRelations) { - const neighbor = frontier.has(relation.sourceId) - ? relation.targetId - : frontier.has(relation.targetId) - ? relation.sourceId - : undefined - if (neighbor && !selected.has(neighbor)) { - candidates.set( - neighbor, - Math.max( - candidates.get(neighbor) ?? 0, - bestEvidenceConfidence(relation.evidence) - ) - ) - } - } - const next = [...candidates] - .sort( - ([leftId, leftScore], [rightId, rightScore]) => - rightScore - leftScore || - (entitiesById.get(leftId)?.name ?? '').localeCompare( - entitiesById.get(rightId)?.name ?? '' - ) - ) - .slice(0, maximumEntities - selected.size) - .map(([id]) => id) - frontier = new Set(next) - for (const id of next) { - selected.add(id) - } - } - const entities = [...selected] - .map((id) => entitiesById.get(id)) - .filter((entity): entity is GraphEntity => entity !== undefined) - const relations = validRelations - .filter( - (relation) => - selected.has(relation.sourceId) && selected.has(relation.targetId) - ) - .sort( - (left, right) => - Number(matchedEntityIds.includes(right.sourceId)) + - Number(matchedEntityIds.includes(right.targetId)) - - Number(matchedEntityIds.includes(left.sourceId)) - - Number(matchedEntityIds.includes(left.targetId)) || - bestEvidenceConfidence(right.evidence) - - bestEvidenceConfidence(left.evidence) || - left.id.localeCompare(right.id) - ) - .slice(0, maximumRelations) - const connected = new Set( - relations.flatMap((relation) => [relation.sourceId, relation.targetId]) - ) - return { - entities: entities.filter( - (entity) => - matchedEntityIds.includes(entity.id) || connected.has(entity.id) - ), - relations, - matchedEntityIds - } -} diff --git a/src/main/magic-notes/canvas-ipc.integration.test.ts b/src/main/magic-notes/canvas-ipc.integration.test.ts index 37d7c2d7..8d1138a6 100644 --- a/src/main/magic-notes/canvas-ipc.integration.test.ts +++ b/src/main/magic-notes/canvas-ipc.integration.test.ts @@ -41,7 +41,7 @@ vi.mock('electron', () => ({ contextBridge: { exposeInMainWorld: (_name: string, api: DesktopApi) => { bridge.api = api } } })) // Disable unrelated environment integrations; persistence, schemas and handlers are real. -vi.mock('../channels/channel-env', () => ({ startEnvironmentChannels: () => [], isReadOnlyChannelMessage: () => true })) +vi.mock('../channels/channel-env', () => ({ startEnvironmentChannels: () => [] })) const png = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' function canvas(): MagicNoteCanvasContent { diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index 446cb784..e54f33fa 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -77,6 +77,17 @@ function makeTokenUsage(): TokenUsageSummary { } describe('ActivityPanel', () => { + it('opens timeline guidance separately from tab navigation', () => { + render() + fireEvent.click(screen.getByRole('tab', { name: '活动时间线' })) + const help = screen.getByRole('button', { name: '活动时间线' }) + expect(help.closest('[role="tab"]')).toBeNull() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + fireEvent.click(help) + expect(screen.getByRole('tooltip')).toHaveTextContent('所有轨道共享同一执行顺序') + expect(screen.getByRole('tab', { name: '活动时间线' })).toHaveAttribute('aria-selected', 'true') + }) + afterEach(async () => { cleanup() await i18n.changeLanguage('zh-CN') @@ -106,6 +117,7 @@ describe('ActivityPanel', () => { screen.getByRole('heading', { level: 1, name: 'Run history' }) ).toBeInTheDocument() expect(screen.queryByText('RUN HISTORY')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Run history' })) expect( screen.getByText( 'Review execution details by project, task, and conversation, or browse the activity timeline and model usage.' @@ -705,8 +717,9 @@ describe('ActivityPanel', () => { ).getByText('G') ).toBeInTheDocument() expect(within(timeline).getByText('项目:项目甲')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '活动时间线' })) expect( - within(timeline).getByText( + screen.getByText( '所有轨道共享同一执行顺序,节点按发生时间依次展开;点击节点可查看身份和活动详情。' ) ).toBeInTheDocument() diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index c5ef5929..4a3dc1e2 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -1,6 +1,7 @@ import { Activity, Trash2 } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { InlineHelp } from './InlineHelp' import type { AssistantProject, TokenUsageSummary @@ -694,7 +695,7 @@ export function ActivityPanel({ className="activity-panel" > } scope={{ kind: 'all-projects' }} @@ -822,9 +823,10 @@ export function ActivityPanel({ aria-label={t('timeline.ariaLabel')} className="activity-tracks" > -

- {t('timeline.description')} -

+
+ {t('tabs.timeline')} + {t('timeline.description')} +
    { vi.mocked(api.conversations.list).mockResolvedValue([{ ...saved, updatedAt: saved.updatedAt + 120_000, messages: saved.messages.map((message) => message.role === "assistant" - ? { ...message, state: "complete", content: "Persisted terminal result" } : message), + ? { + ...message, state: "complete", content: "Persisted terminal result", + blocks: [...(message.blocks ?? []), { + id: crypto.randomUUID(), type: "text" as const, content: "Persisted terminal result", + }], + } : message), }]); act(() => conversationQueueChangeListener?.(request.conversationId!)); await screen.findByText("Persisted terminal result"); @@ -11819,6 +11824,66 @@ describe("App", () => { ); }); + it("keeps interactive questions in event order after a late answer response and history reload", async () => { + const response = deferred(); + vi.mocked(api.agent.respondQuestion).mockReturnValueOnce(response.promise); + const view = render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "Question order regression" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]![0].requestId; + const question: Extract = { + requestId, type: "question", questionId: "ordered-question", + questions: [{ + header: "休息日", question: "你想怎样安排?", + options: [{ label: "探索美食", description: "" }], multiple: false, custom: false, + }], + }; + act(() => { + agentListener?.({ requestId, type: "text", delta: "Before question" }); + agentListener?.(question); + agentListener?.(question); + }); + expect(document.querySelectorAll(".agent-question-card")).toHaveLength(1); + fireEvent.click(screen.getByRole("radio", { name: "探索美食" })); + fireEvent.click(screen.getByRole("button", { name: "提交回答" })); + await waitFor(() => expect(api.agent.respondQuestion).toHaveBeenCalledWith("ordered-question", [["探索美食"]])); + act(() => { + agentListener?.({ + requestId, type: "tool", callId: "after-question", name: "after_question", + state: "completed", summary: "after_question", + }); + agentListener?.({ requestId, type: "text", delta: "Selection received" }); + agentListener?.({ requestId, type: "done" }); + }); + await act(async () => response.resolve()); + const assertOrder = () => { + const card = screen.getByText("问题与回答").closest(".agent-question-review")!; + expect(card.closest(".message-blocks")).not.toBeNull(); + expect(screen.getByText("Before question").compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect(card.compareDocumentPosition(screen.getByText("Selection received")) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + }; + await waitFor(assertOrder); + let persisted: ConversationSnapshot | undefined; + await waitFor(() => { + const saved = vi.mocked(api.conversations.saveLocal).mock.calls + .flatMap(([batch]) => batch) + .filter(item => item.messages.some(message => message.answeredQuestions?.some(answer => answer.questionId === question.questionId))) + .at(-1); + expect(saved).toBeDefined(); + const message = saved!.messages.find(message => message.answeredQuestions?.length)!; + expect(message.blocks?.map(block => block.type)).toEqual(["text", "question", "tool", "text"]); + expect(message.blocks?.filter(block => block.type === "question")).toHaveLength(1); + persisted = { ...saved!.header, messages: saved!.messages }; + }); + view.unmount(); + vi.mocked(api.conversations.list).mockResolvedValue([persisted!]); + render(); + await waitFor(assertOrder); + }); + it("queues concurrent task questions by ID without losing drafts on duplicates or failed replies", async () => { let reject!: (error: Error) => void; vi.mocked(api.agent.respondQuestion).mockImplementationOnce(() => new Promise((_resolve, no) => { reject = no; })); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9709bd38..1ba76754 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -206,6 +206,7 @@ import { } from "./CustomTaskDialog"; import { ConversationTaskStrip } from "./ConversationTaskStrip"; import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip"; +import { appendConversationQuestionBlock } from "../../shared/conversation-question-blocks"; import { ConversationInputQueue } from "./ConversationInputQueue"; import { OverflowMarquee } from "./OverflowMarquee"; import { findTaskSchedule } from "./TaskScheduleActions"; @@ -5082,6 +5083,7 @@ function App(): React.JSX.Element { return { ...message, status: undefined, + blocks: appendConversationQuestionBlock(message.blocks, event.questionId), pendingQuestions: [...(message.pendingQuestions ?? []), event], }; }); diff --git a/src/renderer/src/ApplicationCenter.test.tsx b/src/renderer/src/ApplicationCenter.test.tsx index 56d8ac79..ffb25185 100644 --- a/src/renderer/src/ApplicationCenter.test.tsx +++ b/src/renderer/src/ApplicationCenter.test.tsx @@ -6,6 +6,7 @@ import { within, } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { changeUiLocale } from './i18n' import { applicationSettingsSchema, defaultApplicationNavigation, @@ -40,9 +41,34 @@ const pinnedSettings = { pinned: { 'magic-notes': true, 'local-inference': true }, }, } -afterEach(cleanup) +afterEach(async () => { + cleanup() + await changeUiLocale('zh-CN') +}) describe('Application Center', () => { + it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => { + await changeUiLocale(locale) + const onUpdate = vi.fn(async () => true) + const { container } = render() + const pinLabel = locale === 'zh-CN' ? '常驻左侧菜单' : 'Pin to sidebar' + const explanation = locale === 'zh-CN' ? '常驻只控制左侧捷径。' : 'Pinning only controls the sidebar shortcut.' + const consequence = locale === 'zh-CN' ? /不停止已有请求或后台服务/ : /without stopping existing requests or background services/ + expect(screen.getByText(explanation)).not.toBeVisible() + expect(screen.getByText(consequence)).toBeVisible() + const help = screen.getByRole('button', { name: pinLabel }) + expect(container.querySelector('label .inline-help, button .inline-help, summary .inline-help')).toBeNull() + const pin = screen.getByRole('switch', { name: pinLabel }) + expect(pin).toHaveAccessibleDescription(explanation) + const checked = (pin as HTMLInputElement).checked + fireEvent.click(help) + expect(onUpdate).not.toHaveBeenCalled() + expect((pin as HTMLInputElement).checked).toBe(checked) + expect(screen.getByRole('tooltip')).toHaveTextContent(explanation) + expect(screen.getByRole('tooltip')).not.toHaveTextContent(consequence) + expect(pin).toHaveAccessibleDescription(explanation) + }) + it('returns from compact settings through header navigation without losing the search', () => { render() fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } }) @@ -282,13 +308,17 @@ describe('Application Center', () => { const pageCount = screen.getByRole('combobox', { name: '发送画布页数' }) expect(pageCount).toHaveValue('1') expect(within(pageCount).getAllByRole('option')).toHaveLength(8) - expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量')) expect(pageCount.parentElement).toHaveClass('field') const help = screen.getByRole('button', { name: '发送画布页数' }) - expect(help).toHaveClass('icon-button') - expect(help).toHaveAccessibleDescription(help.getAttribute('title')!) - expect(help).toHaveAttribute('title', expect.stringContaining('不是笔记记录数量')) - expect(document.getElementById('magic-note-canvas-page-count-help')).toHaveClass('sr-only') + expect(help).toHaveClass('inline-help') + expect(help).not.toHaveAttribute('title') + expect(help.closest('label, summary')).toBeNull() + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + fireEvent.click(help) + expect(pageCount).toHaveValue('1') + expect(onUpdate).toHaveBeenCalledTimes(1) + expect(pageCount).toHaveAccessibleDescription(expect.stringContaining('不是笔记记录数量')) + expect(screen.getByRole('tooltip')).toHaveAttribute('id', 'magic-note-canvas-page-count-help') fireEvent.change(pageCount, { target: { value: '8' } }) expect(onUpdate).toHaveBeenLastCalledWith({ magicNoteCanvasPageCount: 8 }) fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' })) diff --git a/src/renderer/src/ApplicationCenter.tsx b/src/renderer/src/ApplicationCenter.tsx index d091021b..1caaddca 100644 --- a/src/renderer/src/ApplicationCenter.tsx +++ b/src/renderer/src/ApplicationCenter.tsx @@ -10,7 +10,6 @@ import { ArrowDown, ArrowLeft, ArrowUp, - CircleHelp, Cpu, HeartPulse, Library, @@ -19,6 +18,7 @@ import { X, } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { InlineHelp } from './InlineHelp' import { defaultApplicationNavigation, type ApplicationSettings, @@ -117,24 +117,28 @@ export function ApplicationSettingsView({ /> {t('applications.enable')} - -

    {t('applications.enableHelp')}

    +
    + + {t('applications.pinHelp')} +
    +

    {t('applications.disableHelp')}

    {id === 'magic-notes' && (