From ca3c919a34b54ac873967055bc5e361e295c0dec Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 18 Sep 2026 15:29:00 +0800 Subject: [PATCH] feat: unify app ordering and simplify app settings access --- FEATURES.md | 14 +- FEATURES.zh-CN.md | 2 +- UI-DESIGN.md | 10 +- .../application-tool-navigation/README.md | 22 +-- .../logic-design.md | 30 ++-- .../navigation-demo.js | 14 +- .../application-tool-navigation/prd.md | 42 +++--- .../application-tool-navigation/progress.md | 38 +++++ .../technical-design.md | 54 ++++--- .../application-tool-navigation/ui-design.md | 49 +++--- .../user-stories.md | 46 +++--- src/main/application-settings-store.test.ts | 67 ++++++++- src/main/application-settings-store.ts | 16 ++ src/renderer/src/App.test.tsx | 100 +++++++++--- src/renderer/src/App.tsx | 24 +-- src/renderer/src/ApplicationCenter.test.tsx | 142 +++++++++++++----- src/renderer/src/ApplicationCenter.tsx | 115 ++++++-------- src/renderer/src/ApplicationMenu.test.tsx | 10 +- src/renderer/src/ApplicationMenu.tsx | 2 +- src/renderer/src/LocalInferencePage.test.tsx | 20 ++- src/renderer/src/LocalInferencePage.tsx | 9 +- src/renderer/src/application-center.css | 13 +- src/renderer/src/i18n/locales/en-US/app.ts | 6 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 6 +- src/shared/application-settings-contracts.ts | 8 +- src/shared/goodbuddy-config-contracts.test.ts | 16 ++ 26 files changed, 543 insertions(+), 332 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index ae78ce11..eea93afa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -114,14 +114,16 @@ records are listed separately and do not introduce another feature status. - [ ] **Application center and navigation** (integration pending): Clicking the bottom App Center opens a lightweight upward anchored popup with enabled apps, regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes - the popup; Local Inference opens a separate modal preserving the workspace, while other apps + the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps open their main content pages. Manage Apps opens searchable cards with settings details; layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md). - Knowledge and Heartbeat are always enabled in stable sidebar - positions before optional apps. Their management rows offer only Open, with no enable, - pin, reorder, or generic empty settings controls. Existing workspace settings and Heartbeat - per-plan enabled states remain unchanged. Only Notes and Local Inference have optional - enablement, pinning, ordering, and shared settings. Unshipped `knowledgeEnabled` and + All four application cards support arrow and drag reordering regardless of enablement or pinning. + Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge and Heartbeat + are always enabled and pinned, labeled Always shown, with Open and reorder controls but no + enable/pin toggles or generic empty settings. Existing workspace settings and Heartbeat + per-plan enabled states remain unchanged. Only Notes and Local Inference Monitor have optional + enablement, pinning, and shared settings. Older optional-app orders retain their relative order + when missing defaults are added. Unshipped `knowledgeEnabled` and `heartbeatEnabled` fields were removed without migrations. Durable settings saves, including configuration-tool writes, synchronize through change events; reopening the center locks edits until refresh completes, and newer snapshots supersede stale reads. diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 8037b5b4..8cf01e1b 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -30,7 +30,7 @@ - [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。 - [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。 - [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。 -- [ ] **应用中心与导航**(整体验收待完成):底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。知识库和智能心跳始终启用,在可选应用之前保持固定侧栏位置,管理行仅有“打开”,不提供启用、常驻、排序或通用空设置;原工作区配置和心跳单条计划 enabled 不变。可选启用、常驻、排序和共同设置仅适用于魔法笔记、本机推理;未发布的 `knowledgeEnabled`、`heartbeatEnabled` 已移除,不增加迁移。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。此前类型检查和 `npx eslint src` 通过;完整 `npm test` 为 4,448 项通过、67 项跳过、2 项失败,仍为未改动的心跳迁移 `duplicate column name: pinned`;全仓 lint 仍有无关 story-graph Demo 的 22 个 `document` 错误。8 个 Electron fixture 主题/尺寸场景覆盖键盘、焦点、几何、原生视图遮挡及真实设置 Store 持久化,不代表完整生产 App 验收。最新验证及剩余边界见[验证进度](./docs/features/application-tool-navigation/progress.md)。 +- [ ] **应用中心与导航**(整体验收待完成):底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库和智能心跳始终启用并常驻,标注“始终显示”,提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置和心跳单条计划 enabled 不变。可选启用、常驻和共同设置仅适用于魔法笔记、本机推理监控。旧可选应用顺序补齐默认项并保留已有相对顺序;未发布的 `knowledgeEnabled`、`heartbeatEnabled` 已移除,不增加开关键迁移。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。此前类型检查和 `npx eslint src` 通过;完整 `npm test` 为 4,448 项通过、67 项跳过、2 项失败,仍为未改动的心跳迁移 `duplicate column name: pinned`;全仓 lint 仍有无关 story-graph Demo 的 22 个 `document` 错误。8 个 Electron fixture 主题/尺寸场景覆盖键盘、焦点、几何、原生视图遮挡及真实设置 Store 持久化,不代表完整生产 App 验收。最新验证及剩余边界见[验证进度](./docs/features/application-tool-navigation/progress.md)。 - [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入监督、统一 Runtime、受管进程、静态安全 HTML 预览、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。Task Center 保持 Task 的单例索引,附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/features/assistant-workbar/prd.md)。 - [ ] **ShareServer Office 协同编辑**(设计中):通过可选 ShareServer 集成 ONLYOFFICE Docs,在助手工作栏中以文件名打开多个文档 Tab,支持 DOCX、XLSX、PPTX 的人工编辑、保存、撤销、源文件冲突保护和后续 AI 选区修改。Office 正文会由所选 ShareServer 和编辑引擎处理;Desktop 不内置或启动 Document Server,未配置服务时不宣称离线编辑。详见[设计文档](./docs/features/office-document-editing/README.md)。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 78dc1891..9874b44c 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -315,11 +315,11 @@ 接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不 超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时 单行截断,不能撑宽或挤压侧栏。 -- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。 - 知识库、智能心跳始终启用,依次固定在可选应用之前;不参与启用、常驻或排序,管理 Modal 仅提供“打开”,不增设通用空设置。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理可启用、常驻与排序,取消常驻不关闭功能。 +- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。 + 四个内置应用的管理卡片、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。 设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。 - 管理 Modal 使用桌面双列、窄窗口单列卡片;可选卡片提供“打开”和“设置”,关闭时禁用“打开”。启用、常驻和上下移动位于紧凑设置详情,卡片保留拖动排序。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。 -- 本机推理 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。 + 管理 Modal 按可用宽度自适应排列卡片,宽度足够时显示三列及以上,窄窗口单列;可选卡片提供“打开”和“设置”,关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四张卡片均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。 +- 本机推理监控 Modal 复用整窗 Portal、背景隔离和焦点循环;停止、重启及释放使用其上方的紧凑影响确认。Escape 只关闭最上层,操作提交期间禁止关闭及重复提交;焦点恢复与尺寸见[本机推理监控 UI 规则](./docs/features/application-tool-navigation/ui-design.md#91-本机推理管理)。 ### 6.9 助手工作栏 @@ -758,7 +758,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 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 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 - “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。 - OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。 -- 魔法笔记设置由应用中心与笔记页共享;全局设置不再提供魔法笔记页签或跳转入口。“显示未完成待办数量”持久 Switch 只控制左侧数量徽标,不改变待办数据。旧入口开关绑定业务启用值,迁为“启用应用”并保留原值,常驻使用独立配置。默认开启仅适用于未配置布尔值,只有独立显隐偏好才映射到常驻。此迁移规则见[应用导航 PRD](./docs/features/application-tool-navigation/prd.md#fr-11-应用导航设置)。 +- 魔法笔记设置统一从应用中心卡片进入;笔记页和全局设置不再提供重复入口。“显示未完成待办数量”持久 Switch 只控制左侧数量徽标,不改变待办数据。旧入口开关绑定业务启用值,迁为“启用应用”并保留原值,常驻使用独立配置。默认开启仅适用于未配置布尔值,只有独立显隐偏好才映射到常驻。此迁移规则见[应用导航 PRD](./docs/features/application-tool-navigation/prd.md#fr-11-应用导航设置)。 - MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。 - MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。 - 内置 MCP 中的“图片生成与编辑”是自动管理例外:该行始终可见,标记“内置 · 自动管理”,不提供独立启停、分配保存、移除或凭据编辑。直连 Model、OpenCode、Continue、DeepSeek Harness 的分配只由已保存图片模型的“允许 AI 在会话中调用”设置推导;至少一个开启则全部勾选,没有开启模型则全部取消。使用禁用的原生 Checkbox、Runtime 标签及关联说明,保留可聚焦的“前往图片模型设置”导航;远程适用范围通过 GoodBuddy Agent 说明持续可见。Ask、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。 diff --git a/docs/features/application-tool-navigation/README.md b/docs/features/application-tool-navigation/README.md index e3a90619..00c7ed13 100644 --- a/docs/features/application-tool-navigation/README.md +++ b/docs/features/application-tool-navigation/README.md @@ -1,16 +1,16 @@ # 应用导航与系统工具入口 -本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,知识库和智能心跳作为固定内置应用始终启用并显示; -魔法笔记和本机推理可通过应用管理控制启用、常驻及排序。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供 +本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库和智能心跳始终启用并常驻; +魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供 商店入口、推荐位或安装流程。 当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。 -本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。侧栏依次显示知识库、智能心跳,再显示按偏好过滤的可选应用。两个固定应用在管理 Modal 中仅提供“打开”,不参与启用、常驻或排序,不增设通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。 +本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理监控。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。卡片、菜单和侧栏按同一保存顺序排列,侧栏按启用和常驻过滤。知识库和智能心跳在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。 新增计划包括 [私有化市场与 YAML 交换](./prd.md#fr-15-私有化市场与-yaml-交换后续): 通过市场 URL 获取应用,支持 `.yml` 导入导出,未来由 ShareServer 提供私有目录与分发。 [本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)已独立于商店接入,作为 -可常驻左侧的“本机推理”内置应用,直接查看语音、OCR、向量等本机服务状态与可用资源指标,不显示任务历史。向量服务显示独立进程的 CPU 和工作集内存;ASR、OCR 与宿主共享进程,逐行说明无法独立统计的原因。 +可常驻左侧的“本机推理监控”(英文 Local Inference Monitor)内置应用,直接查看语音、OCR、向量等本机服务状态与可用资源指标,不显示任务历史。向量服务显示独立进程的 CPU 和工作集内存;ASR、OCR 与宿主共享进程,逐行说明无法独立统计的原因。 本轮已接入 `local-inference` 管理 Modal、类型化 IPC、真实请求登记及引擎管理;实际支持清单见 [技术设计](./technical-design.md#111-本机推理任务与执行服务),市场仍为后续需求。 相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。 @@ -23,7 +23,7 @@ ## 文档导航 -2026-09-16 后续应用组织决策:计划将现有“智能心跳”入口整合、升级为“监督者”,内部包含工作回顾和故事线图谱。未来市场复用同一应用身份,不增加平级重复入口。该更名与整合尚未实施;当前智能心跳为固定应用,不提供应用级启用、常驻或排序。目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。 +2026-09-16 后续应用组织决策:计划将现有“智能心跳”入口整合、升级为“监督者”,内部包含工作回顾和故事线图谱。未来市场复用同一应用身份,不增加平级重复入口。该更名与整合尚未实施;当前智能心跳始终显示、可排序,不提供应用级启用或常驻开关。目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。 | 文档 | 权威职责 | | --- | --- | @@ -40,7 +40,7 @@ Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无 A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单独演示未来私有市场。 模型服务、资源指标和工具内容均为演示数据,不执行真实安装、模型加载或进程操作。 窄屏上下排列侧栏与主内容,应用页面隐藏输入区,对话页面显示输入区。 -Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理 Modal 的交付范围以技术清单和实施进度为准,旧页面原型不作为 Modal 验收依据。 +Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理监控 Modal 的交付范围以技术清单和实施进度为准,旧页面原型不作为 Modal 验收依据。 底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、 知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名 附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止, @@ -55,17 +55,17 @@ Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理 Modal Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧 工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。 -Demo 待同步更正:本节仅描述旧原型,不是生产行为或验收依据。原型直接打开 Modal,以及知识库和智能心跳的启停、常驻、排序、通用设置,均已过时;生产采用上述轻量菜单及两个固定应用规则。本轮不修改 Demo 文件。 +Demo 待同步更正:本节仅描述旧原型,不是生产行为或验收依据。原型直接打开 Modal,以及知识库和智能心跳的启停、常驻、通用设置,均已过时;原型的常驻排序也不代表生产中四个应用的共享顺序。生产规则见上述说明及 UI 设计。Demo 已同步“本机推理监控”名称及其默认不常驻状态。 六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用 -上下箭头排序、移除入口。魔法笔记、知识库、智能心跳及计划预览“本机推理管理”默认全部启用且常驻。 +上下箭头排序、移除入口。内置应用默认全部启用;魔法笔记、知识库、智能心跳默认常驻,计划预览“本机推理监控”默认不常驻。 取消常驻仅移除导航捷径,仍可从应用中心打开;关闭应用隐藏业务入口,应用中心保留设置与 -重新开启入口,并记住常驻偏好。当前应用页关闭后显示不可用状态及设置恢复按钮。 +重新开启入口,并记住常驻偏好。当前应用页关闭后显示不可用状态,重新启用从应用中心卡片设置进入。 知识库关闭时同步隐藏侧栏入口、会话选项中的知识范围和检索方式、已选知识标签;重新开启 恢复原来的选择。以上操作不删除数据,也不表示停止后台任务或模型服务。 “恢复默认应用”重置应用安装、启用、常驻和笔记设置,返回对话页;模型服务模拟状态保留至刷新。 -所有内置应用提供应用中心、应用页面和全局设置入口,均打开同一应用设置面板,分别控制 +通用应用设置统一从应用中心卡片进入,应用页面和全局设置不提供重复入口,分别控制 启用与常驻。魔法笔记面板另含显示未完成待办数量、AI 评论方式 (即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示 魔法笔记入口”不在原型中重复提供。正式迁移保留其业务启用值,常驻使用独立配置。启用、常驻、待办数量显示默认开启; @@ -104,7 +104,7 @@ Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快 | 术语 | 定义 | | --- | --- | | 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 | -| 应用 | 可显示在主导航区的功能;知识库、智能心跳和魔法笔记打开一级页面,本机推理打开保留底层工作区的 Modal | +| 应用 | 可显示在主导航区的功能;知识库、智能心跳和魔法笔记打开一级页面,本机推理监控打开保留底层工作区的 Modal | | 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,由右侧助手工作栏 Tab 承载 | | 插件应用 | 未来由插件提供的一级页面入口 | | 插件工具 | 未来由插件提供的辅助面板或命令入口 | diff --git a/docs/features/application-tool-navigation/logic-design.md b/docs/features/application-tool-navigation/logic-design.md index 39c35c84..9c972046 100644 --- a/docs/features/application-tool-navigation/logic-design.md +++ b/docs/features/application-tool-navigation/logic-design.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 | +| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 | | 版本 | 0.4 | | 日期 | 2026-09-17 | | 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) | @@ -16,7 +16,7 @@ 2. 运行记录不进入工具注册表、应用显隐设置或插件市场。 3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。 4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。 -5. 魔法笔记、本机推理的启用、常驻及顺序是独立状态。知识库和智能心跳始终启用并固定显示,不参与这些状态;取消可选应用常驻不关闭功能,关闭不删除数据或导航偏好。 +5. 四个内置应用共享保存顺序。知识库和智能心跳始终启用并常驻,位置可调整;魔法笔记、本机推理监控可独立设置启用和常驻。取消常驻不关闭功能,关闭不删除数据或导航偏好。 6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。 7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。 8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。 @@ -34,7 +34,7 @@ type NavigationContribution = type: 'application' id: 'knowledge' | 'heartbeat' source: 'builtin' - fixed: true + alwaysShown: true } | { type: 'application' @@ -51,7 +51,7 @@ type NavigationContribution = ``` 该模型是首期的逻辑投影,启用值来自各应用的唯一业务配置,常驻及顺序来自导航偏好。 -应用清单为魔法笔记、知识库、智能心跳、本机推理,打开无需安装。市场不加入本轮清单。 +应用清单为魔法笔记、知识库、智能心跳、本机推理监控,打开无需安装。市场不加入本轮清单。 ## 3. 主导航组合规则 @@ -59,20 +59,18 @@ type NavigationContribution = ```text 当前对话 -知识库(固定显示) -智能心跳(固定显示) -可选应用(按保存顺序,仅显示启用且常驻项) +应用(按保存顺序;知识库和智能心跳始终显示,可选应用仅显示启用且常驻项) 运行记录 ``` -当前对话、知识库、智能心跳和运行记录不参与排序。可选应用默认顺序为魔法笔记、本机推理,默认均常驻。两个固定应用始终位于可选应用之前,过滤可选项不留下空白。 +当前对话、运行记录和设置不参与应用排序。默认应用顺序为知识库、智能心跳、魔法笔记、本机推理监控;魔法笔记默认常驻,本机推理监控默认不常驻。过滤可选项不留下空白。 常驻、取消常驻和排序按设备保存,重启后恢复。排序只发生在应用区,不移动固定系统入口。 -保存完整可选应用顺序,关闭或取消常驻只过滤主导航,不删除顺序记录;重新开启或常驻时按 -该顺序恢复。管理列表编辑完整可选应用顺序;上下移动和拖动共用同一顺序写入,两个固定应用不作为拖动项或目标。 +管理卡片、应用菜单和主导航共用完整应用顺序,分别按搜索、启用、启用且常驻过滤;过滤不删除顺序记录,重新开启或常驻时按保存顺序恢复。 +每张管理卡片均可排序,包括关闭或未常驻的应用。上下移动以完整保存顺序中的相邻应用为目标,拖动使用同一移动规则。搜索时禁用拖动,箭头仍按完整顺序移动,保留未命中应用及其相对顺序。 ### 3.1 应用可见性与业务状态 -下表仅适用于可选应用。知识库和智能心跳始终可用,管理 Modal 中仅有“打开”,保留原工作区实际配置,不增加通用空设置页。心跳单条计划 enabled 仍按原业务规则控制计划。 +下表仅适用于可选应用。知识库和智能心跳始终可用,管理 Modal 中提供“打开”和排序,保留原工作区实际配置,不增加启用、常驻开关或通用空设置页。心跳单条计划 enabled 仍按原业务规则控制计划。 | 常驻偏好 | 功能启用 | 结果 | | --- | --- | --- | @@ -81,8 +79,8 @@ type NavigationContribution = | 是 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住常驻及顺序 | | 否 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住未常驻状态 | -管理 Modal 浏览和搜索包含关闭的可选应用,搜索不修改启用或常驻。轻量菜单仅显示已启用项,点击行先关闭菜单;本机推理打开独立 Modal,其余应用请求主内容区导航。管理 Modal 的“打开”和常驻入口遵守同一规则。打开已启用应用不自动常驻; -取消当前应用的常驻不关闭当前页面或本机推理 Modal。关闭应用后不能从旧入口继续执行新的业务操作。 +管理 Modal 浏览和搜索包含关闭的可选应用,搜索不修改启用或常驻。轻量菜单仅显示已启用项,点击行先关闭菜单;本机推理监控打开独立 Modal,其余应用请求主内容区导航。管理 Modal 的“打开”和常驻入口遵守同一规则。打开已启用应用不自动常驻; +取消当前应用的常驻不关闭当前页面或本机推理监控 Modal。关闭应用后不能从旧入口继续执行新的业务操作。 停用时仍允许管理常驻偏好,界面说明重新开启后才会显示入口。 ### 3.2 关闭应用与恢复 @@ -91,7 +89,7 @@ type NavigationContribution = | 对象 | 关闭后的结果 | 重新开启后的结果 | | --- | --- | --- | -| 当前已打开应用页或本机推理 Modal | 原位置显示“应用已关闭”的不可用状态,提供应用设置恢复入口;保留内容状态及草稿,不强制跳转 | 恢复可用性;仍打开时恢复内容,已关闭或离开则不自动打开 | +| 当前已打开应用页或本机推理监控 Modal | 原位置显示“应用已关闭”的不可用状态;保留内容状态及草稿,不强制跳转。重新启用从应用中心卡片设置进入,监控 Modal 需先关闭 | 恢复可用性;仍打开时恢复内容,已关闭或离开则不自动打开 | | 已在执行的请求及后台操作 | 沿用功能自身的取消、完成和清理生命周期;本次应用开关不新增取消策略 | 不自动恢复、中断后重跑或补发任务 | | 数据、常驻及顺序 | 保留原值,业务入口按启用状态过滤 | 按保存的常驻及顺序恢复入口,不把未常驻应用自动常驻 | @@ -99,11 +97,11 @@ type NavigationContribution = ### 3.3 魔法笔记设置迁移 -魔法笔记设置迁入应用中心的共同设置页;笔记页面定位到同一页,全局设置移除该入口, +魔法笔记设置迁入应用中心卡片的设置页;笔记页面和全局设置均不提供重复入口, 编辑同一配置。启用、常驻、显示未完成待办数量分别控制功能、导航和徽标,不保留重复的 “显示魔法笔记入口”开关。 -未配置的应用启用、常驻及魔法笔记相关布尔项默认开启;已有明确保存的 `false` 必须保留。 +未配置的应用启用及魔法笔记相关布尔项默认开启;魔法笔记默认常驻,本机推理监控默认不常驻,仍可从应用菜单及管理 Modal 打开。已有保存的 `true` 和 `false` 均保留。 AI 评论方式和评论形式继续为单选,沿用现有默认值“即时”和“长评 + 要点”,不能解释为 全部模式同时启用。迁移保留既有单选值及业务数据。关闭数量显示只隐藏徽标,不修改待办。 diff --git a/docs/features/application-tool-navigation/navigation-demo.js b/docs/features/application-tool-navigation/navigation-demo.js index 7deb6de3..80d83832 100644 --- a/docs/features/application-tool-navigation/navigation-demo.js +++ b/docs/features/application-tool-navigation/navigation-demo.js @@ -14,7 +14,7 @@ const items = [ { id: 'notes', name: '魔法笔记', icon: 'note', detail: '记录想法、整理待办', summary: '把刚才的灵感留在这里', builtin: true }, { id: 'knowledge', name: '知识库', icon: 'book', detail: '整理资料、检索知识', summary: '下一次提问,从已有知识开始', builtin: true }, { id: 'heartbeat', name: '智能心跳', icon: 'activity', detail: '管理定时任务与主动提醒', summary: '让例行任务按计划发生', builtin: true }, - { id: 'models', name: '本机推理管理', icon: 'layers', detail: '语音、OCR、向量任务统一管理 · 计划预览', summary: '查看本机推理任务、使用方和资源占用', builtin: true }, + { id: 'models', name: '本机推理监控', icon: 'layers', detail: '语音、OCR、向量任务统一管理 · 计划预览', summary: '查看本机推理任务、使用方和资源占用', builtin: true }, { id: 'projects', name: '项目管理', icon: 'layers', detail: '跟踪项目、任务与进度', summary: '把一个想法变成可跟进的项目', builtin: false }, { id: 'reading', name: '阅读空间', icon: 'book', detail: '集中阅读、批注与摘录', summary: '给长文和深度阅读留一个空间', builtin: false } ]; @@ -135,8 +135,8 @@ window.addEventListener('scroll', () => { if ($('#project-activity').matches(':p renderProjects(); let variant = 'dock'; let catalogMode = 'apps'; -let residentApps = items.filter(item => item.builtin).map(item => item.id); -let enabledApps = new Set(residentApps); +let residentApps = items.filter(item => item.builtin && item.id !== 'models').map(item => item.id); +let enabledApps = new Set(items.filter(item => item.builtin).map(item => item.id)); let currentPage = '当前对话'; let settingsApp = 'notes'; let marketReady = false; @@ -234,16 +234,14 @@ function openPage(name, keepSettings = false) { $('.composer-area').hidden = Boolean(app) || name === '设置' || name === '运行记录'; $('#page-content').classList.toggle('app-page', $('.composer-area').hidden); for (const panel of ['#composer-options', '#context-details']) if ($(panel).matches(':popover-open')) $(panel).hidePopover(); - $('#page-actions').innerHTML = app ? `` : ''; + $('#page-actions').replaceChildren(); if (app && !enabledApps.has(app.id)) { description.textContent = '此应用已关闭,当前页面不可用。已有数据和选择保留;此操作不停止后台任务或模型服务。'; - const recovery = document.createElement('button'); recovery.dataset.appSettings = app.id; recovery.textContent = '前往设置,重新开启'; $('#page-content').append(recovery); renderApps(); return; } if (app?.id === 'models') renderModels(); if (name === '魔法笔记' || name === '设置') { const summary = document.createElement('p'); summary.id = 'note-settings-summary'; summary.className = 'muted'; $('#page-content').append(summary); - if (name === '设置') items.filter(item => item.builtin).forEach(item => { const button = document.createElement('button'); button.dataset.appSettings = item.id; button.textContent = `${item.name}设置`; $('#page-content').append(button); }); renderAppSettings(); } document.querySelectorAll('.navigation button').forEach(button => button.classList.toggle('selected', button.dataset.page === name)); @@ -269,7 +267,7 @@ function selectTool(id) { document.querySelectorAll('[data-tool]').forEach(button => { button.setAttribute('aria-selected', String(button.dataset.tool === id)); button.tabIndex = button.dataset.tool === id ? 0 : -1; }); } function renderModels() { - $('#page-content').innerHTML = `

本机推理管理

统一查看语音、OCR、向量等本机推理任务与执行服务。任务进度、引擎及占用均为模拟数据。

推理任务与服务

下方操作作用于执行服务;启动或加载服务不会自动恢复已中断的任务。

${modelServices.map(service => { + $('#page-content').innerHTML = `

本机推理监控

统一查看语音、OCR、向量等本机推理任务与执行服务。任务进度、引擎及占用均为模拟数据。

推理任务与服务

下方操作作用于执行服务;启动或加载服务不会自动恢复已中断的任务。

${modelServices.map(service => { const external = service.management === '外部管理'; const active = service.management === '进程内' ? '已加载' : '运行中'; const actions = external ? ['config'] : service.management === '进程内' ? [service.running ? 'release' : 'load'] : service.running ? ['stop', 'restart'] : ['start']; @@ -440,7 +438,7 @@ document.addEventListener('keydown', event => { if (event.key === 'Enter' && document.activeElement === $('#search')) { event.preventDefault(); actions[0].click(); } }); $('#theme').onclick = () => { const dark = document.documentElement.dataset.theme !== 'dark'; document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme').textContent = dark ? '切换浅色' : '切换深色'; }; -$('#reset').onclick = () => { installed = new Set(items.filter(item => item.builtin).map(item => item.id)); residentApps = [...installed]; enabledApps = new Set(installed); Object.assign(noteSettings, { showCount: true, mode: 'immediate', format: 'combined' }); editingApps = false; definitions.clear(); importDraft = null; $('#import-preview').hidden = true; renderApps(); renderComposer(); openPage('当前对话'); notify('应用启用、常驻和笔记设置已恢复默认'); }; +$('#reset').onclick = () => { installed = new Set(items.filter(item => item.builtin).map(item => item.id)); residentApps = [...installed].filter(id => id !== 'models'); enabledApps = new Set(installed); Object.assign(noteSettings, { showCount: true, mode: 'immediate', format: 'combined' }); editingApps = false; definitions.clear(); importDraft = null; $('#import-preview').hidden = true; renderApps(); renderComposer(); openPage('当前对话'); notify('应用启用、常驻和笔记设置已恢复默认'); }; let demoRunning = false; let demoRecording = false; let demoAttachments = []; diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md index d23f3d04..28837b82 100644 --- a/docs/features/application-tool-navigation/prd.md +++ b/docs/features/application-tool-navigation/prd.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 | +| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 | | 版本 | 0.4 | | 日期 | 2026-09-17 | | 适用产品 | GoodBuddy 桌面端 | @@ -29,8 +29,8 @@ | 类型 | 首期成员 | 默认位置 | 用户控制范围 | | --- | --- | --- | --- | | 系统入口 | 当前对话、运行记录、设置 | 主导航固定位置或侧栏底部 | 不可隐藏、卸载或改变类型 | -| 固定内置应用 | 知识库、智能心跳 | 主导航固定位置及应用中心 | 始终启用并显示,不可启停、取消常驻或排序;保留工作区实际配置及心跳单条计划 enabled | -| 可选内置应用 | 魔法笔记、本机推理 | 应用中心及主导航常驻区 | 独立控制启用、常驻与排序;保留数据 | +| 始终显示的内置应用 | 知识库、智能心跳 | 主导航应用区及应用中心 | 始终启用并常驻,可排序,不提供启用或常驻开关;保留工作区实际配置及心跳单条计划 enabled | +| 可选内置应用 | 魔法笔记、本机推理监控 | 应用中心及主导航常驻区 | 独立控制启用、常驻与排序;保留数据 | | 系统工具 | 文件浏览器、终端、浏览器、资源监控 | 右侧助手工作栏 Tab 和“+”目录 | 自带且不可卸载,按工作栏规则管理实例 | 运行记录是系统审计页面,不属于工具。文件浏览器、终端、浏览器和资源监控是系统工具;它们 @@ -44,9 +44,9 @@ ## 3. 目标 - 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。 -- 主导航承载应用入口,本机推理打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。 +- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。 - 运行记录始终可发现,不受应用显示设置和插件状态影响。 -- 可选内置应用可独立设置启用与常驻,支持排序;知识库和智能心跳始终显示,未常驻的已启用应用仍可从应用菜单打开。 +- 四个内置应用均支持排序;可选应用可独立设置启用与常驻,知识库和智能心跳始终显示,未常驻的已启用应用仍可从应用菜单打开。 - 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。 ## 4. 非目标 @@ -75,7 +75,7 @@ ### FR-3 内置应用区 -主导航在当前对话之后依次显示固定知识库、智能心跳,再按用户顺序显示已启用且常驻的可选应用,运行记录位于应用区之后。两个固定应用不参与排序。本机推理入口打开独立 Modal,其余应用打开对应一级页面,保留 +主导航在当前对话之后按共享保存顺序显示应用,知识库和智能心跳始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。四个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留 现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库和智能心跳只提供“打开”。 ### FR-4 隐藏应用 @@ -86,7 +86,7 @@ ### FR-5 应用中心入口 -侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理打开 Modal,其余应用在主内容区打开页面。 +侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。 菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。 首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。 @@ -127,16 +127,16 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 ### FR-11 应用导航设置 -管理 Modal 为魔法笔记和本机推理提供应用设置、常驻管理和排序。知识库和智能心跳只提供“打开”,不添加通用空设置页;原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存, +管理 Modal 的四张卡片均提供上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库和智能心跳提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存, 修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。 -魔法笔记设置迁入共同设置界面,应用中心与笔记页访问同一份配置,全局设置不再提供魔法笔记入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。 +魔法笔记设置统一从应用中心卡片进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。 ### FR-12 偏好持久化 -可选应用启用、常驻和顺序按设备持久化,重启后恢复。未配置的启用与常驻默认开启; -魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已明确保存的关闭和排序选择, +四个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控。未配置的应用默认启用,魔法笔记默认常驻,本机推理监控默认不常驻; +魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择, 旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置, -只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。偏好仅包含 `magic-notes` 和 `local-inference`;未发布草案中的 `knowledgeEnabled`、`heartbeatEnabled` 已删除,不增加迁移或兼容读取。 +只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。未发布草案中的 `knowledgeEnabled`、`heartbeatEnabled` 已删除,不增加开关键兼容读取。 ### FR-13 窄窗口行为 @@ -173,7 +173,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 - 主导航能明确区分固定系统入口和可配置内置应用。 - 运行记录在所有应用显隐组合下保持可见并可打开。 - 左下角不再用“本地工作区”与系统架构占据主要空间。 -- 应用菜单只列已启用应用,点击行关闭菜单;本机推理打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。知识库和智能心跳固定可用,只有两个可选应用可管理启用、常驻与顺序,首期无商店入口。 +- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。四个应用均可排序,知识库和智能心跳始终可用,只有两个可选应用可管理启用与常驻,首期无商店入口。 - 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。 - 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、 最小化后停止请求新样本。 @@ -187,9 +187,9 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 ### 首期 - 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。 -- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理入口,不要求安装。本机推理 Modal 与服务单独实现和验证。 -- 支持魔法笔记、本机推理常驻主导航、取消常驻和排序;知识库和智能心跳固定显示,已启用未常驻应用仍可从菜单打开,隐藏不删除数据或停止后台任务。 -- 应用中心和应用页面就近提供已有功能设置,共享真实设置来源;已有全局设置入口定位到同一界面。 +- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理监控入口,不要求安装。本机推理监控 Modal 与服务单独实现和验证。 +- 四个应用共享排序;支持魔法笔记、本机推理监控常驻主导航及取消常驻,知识库和智能心跳始终显示。已启用未常驻应用仍可从菜单打开,隐藏不删除数据或停止后台任务。 +- 通用应用设置统一从应用中心卡片进入;应用页面保留实际业务配置,本机推理监控保留“模型与连接设置”。 魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。 - 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。 - 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。 @@ -201,18 +201,18 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止 ### 补充要求:魔法笔记迁移与入口联动 -- 将现有魔法笔记设置迁入应用中心的应用设置界面,笔记页面内的设置入口访问同一份配置。 +- 将现有魔法笔记设置迁入应用中心卡片的设置界面,移除笔记页面内的重复入口。 魔法笔记可常驻主导航;移除原全局设置入口,保留已有配置值和业务数据。 - 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。 AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。 新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。 -- 知识库和智能心跳为固定内置应用,侧栏入口始终存在;会话知识范围、检索方式、标签及请求行为继续遵守知识库自身规则,心跳单条计划 enabled 仍由心跳页面管理。 +- 知识库和智能心跳侧栏入口始终存在且可调整位置;会话知识范围、检索方式、标签及请求行为继续遵守知识库自身规则,心跳单条计划 enabled 仍由心跳页面管理。 - 可选应用“取消常驻”只移除主导航捷径;“关闭应用”移除该应用在业务界面的入口。 应用中心保留管理和重新开启入口,重新开启沿用常驻偏好,不自动导航。 - 入口变化不删除已有数据。关闭后已打开的可选应用页显示不可用状态及恢复设置入口。 已发出的请求和后台任务沿用各功能的生命周期规则,关闭入口不新增强制取消或重放行为。 -补充验收:关闭全部可选应用后,知识库和智能心跳侧栏入口仍可用,知识会话入口及心跳计划状态不变;管理 Modal 的两个固定应用行仅有“打开”,不能启停、取消常驻或排序。验证魔法笔记未配置时的默认开启,以及 +补充验收:关闭全部可选应用后,知识库和智能心跳侧栏入口仍可用,知识会话入口及心跳计划状态不变;管理 Modal 的两个始终显示应用提供“打开”和排序,不能启停或取消常驻。验证魔法笔记未配置时的默认开启,以及 已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。 ### 后续:应用商店与扩展 @@ -249,7 +249,7 @@ YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时 ### FR-16 本机模型管理入口(新增计划项) -应用中心提供“本机推理”内置管理应用,可选择常驻左侧。从常驻入口、应用菜单或管理 Modal 的“打开”均进入同一独立管理 Modal;来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话及草稿保持不变。该入口统一 +应用中心提供“本机推理监控”内置管理应用,可选择常驻左侧。从常驻入口、应用菜单或管理 Modal 的“打开”均进入同一独立管理 Modal;来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话及草稿保持不变。该入口统一 查看本机语音识别、OCR、向量生成等服务的运行状态与可用资源指标。 Modal 只有服务列表,显示名称、简短状态、引擎、模型、资源和错误,不显示任务历史、任务页签、单任务取消或外部连接列表。独立向量进程显示 CPU 与工作集内存,并标明进程范围;CPU 预热、采样失败和共享进程 worker 无法独立统计时说明原因,不能用零或整个应用进程占用代替服务指标。活动请求可能仍在排队或加载,不能据此宣称服务正在运行。ASR 当前仅显示按需运行且状态未知;OCR 和向量服务使用各自执行模块的状态。 @@ -261,6 +261,6 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资 启动进程。隐藏管理入口、关闭管理 Modal 或取消应用常驻均不停止模型;卸载模型文件是独立操作。 确认打开时不能关闭底层管理 Modal;Escape 只关闭最上层,提交期间禁止关闭及重复执行。关闭后恢复可用入口焦点,具体规则见 [UI 设计](./ui-design.md#91-本机推理管理)。 -资源监控仍是只读系统工具,本机推理聚焦本地服务状态。该项独立于商店接入生产源码,支持 OCR 空闲加载与释放、内置向量进程启动/停止/重启;TTS 显示不可用。后端保留请求登记及原有取消能力,供执行路径和服务影响校验使用。各引擎边界见 +资源监控仍是只读系统工具,本机推理监控聚焦本地服务状态。该项独立于商店接入生产源码,支持 OCR 空闲加载与释放、内置向量进程启动/停止/重启;TTS 显示不可用。后端保留请求登记及原有取消能力,供执行路径和服务影响校验使用。各引擎边界见 [技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。 原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。 diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index bd22e4a5..1c30dab7 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,43 @@ # 应用导航实施进度 +## 2026-09-18:收拢通用应用设置入口 + +对应 FR-11、US-B9。移除魔法笔记页面及本机推理监控停用状态的通用“应用设置”按钮,启用、常驻和笔记评论配置统一从应用中心卡片进入。保留管理应用、设置详情返回导航、模型与连接设置及原业务控件。删除仅用于页面快捷入口的 Context、跳转回调和初始详情参数;停用时仍保留页面挂载状态。现行入口规则见 [UI 设计](./ui-design.md#41-共同应用设置页),Demo 已移除页面、停用恢复及全局设置中的重复快捷入口。 + +验证:App 入口与状态同步定向测试 14 项通过;ApplicationCenter、ApplicationMenu、LocalInferencePage、SettingsPanel 共 128 项通过。首次包含完整 App 文件的运行出现两项既有任务审批失败,以及新增笔记测试未启用 fixture 的失败;修正 fixture 后相关定向测试通过。两项审批失败与上次记录一致,本轮未修改。类型检查、全仓 lint、Demo 语法及 diff 检查通过。本轮未运行全量测试或 Electron 窗口验收,未调用模型,未创建提交;保留已有四应用排序、监控名称和默认不常驻改动。 + +## 2026-09-18:四应用共享顺序 + +对应 FR-3、FR-11、FR-12、US-B7。四张管理卡片均提供上下移动和拖动,包含关闭、未常驻以及始终显示的应用;卡片、菜单、侧栏共用 `applicationNavigation.order`。知识库和智能心跳保留无开关的始终启用、常驻行为,位置可调整,标签改为“始终显示”/`Always shown`。当前对话、运行记录、设置仍保持系统位置。搜索中箭头按完整顺序移动,拖动禁用。保留此前 `260px` 自适应网格、本机推理监控名称及默认不常驻。 + +权威设置返回及写入 schema 接受四个 ID 各一次,拒绝重复、未知和缺失项。仅存储读取补齐旧可选应用顺序及部分默认值,保留已有顺序、显式布尔值和无关设置;规则见[持久化与 IPC](./technical-design.md#43-持久化与-ipc)。没有新增存储版本、应用启用字段或第二份导航状态。 + +验证:`npm test -- src/main/application-settings-store.test.ts src/shared/goodbuddy-config-contracts.test.ts src/main/goodbuddy-config-service.test.ts src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/renderer/src/LocalInferencePage.test.tsx src/renderer/src/WorkspacePrimitives.test.tsx` 共 127 项通过;App 排序保存、菜单与侧栏同步、外部事件、重开及入口行为的 9 项定向测试通过。包含完整 `App.test.tsx` 的八文件运行共 390 项通过、2 项失败,失败项是任务中心审批测试在折叠列表中找不到任务行(`App.test.tsx` 的 scheduled approvals 场景),本次没有修改该行为。`npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。全量 `npm test` 运行约七分钟仍未得到汇总,已停止本次测试进程;日志复现上述两项失败,不能宣称全量通过。 + +Windows Electron fixture 使用当前 ApplicationCenter、生产 CSS/中文翻译、真实 ApplicationSettingsStore 和临时 IPC,从旧两项顺序读取后,移动知识库以及关闭且未常驻的本机推理监控,并用新 Store 实例确认磁盘结果。浅深主题各检查 1280、960、640、375px 目标宽度,实际 CSS 视口因缩放多约 1px;均显示八个排序箭头、四个可拖动卡片,无横向溢出,列数分别为三、三、二、一。详情不含排序,返回搜索焦点和重开顺序通过。最终证据目录为临时 `all-app-order-t0hDya`;已查看桌面浅色及窄屏深色截图。此项是组件与存储验证,未验收完整生产 Main/Preload 启动或安装包,原生指针拖放由组件事件测试之外另行验收。 + +中文文档运行 deai-writing 扫描,无阻断项;对本次改动的复核候选人工检查,保留明确的产品状态与验收限制。未调用模型,未修改模型管理、GPU 指标或 Agent 执行路径,未创建提交。下方记录保留各次实施时的行为,旧“固定位置”“仅常驻项排序”规则已由本条及现行设计替代。 + +## 2026-09-18:卡片排序与自适应列数 + +对应 FR-11、US-B7、US-D1、US-D2。上下移动从设置详情移到常驻可选应用卡片,箭头名称及提示包含应用名;首尾、唯一常驻项和保存锁定时禁用对应操作。拖动也仅允许常驻可选应用,固定应用及未常驻项不参与排序。卡片改用 `auto-fit/minmax`,最小宽度 `260px`,Modal 最大宽度仍为 `960px`;启用、常驻及笔记设置继续使用原详情表单。保留本机推理监控名称及默认不常驻改动。 + +Windows Electron 组件 fixture 使用当前 ApplicationCenter、生产 CSS/中文 i18n、真实 ApplicationSettingsStore 及临时 IPC。浅深主题各检查 1280、960、640、375px 目标窗口宽度,实际 CSS 视口受缩放约多 1px;分别为三、三、二、一列,均无横向溢出。检查排序持久化、详情无排序及返回搜索焦点,并人工查看桌面浅色、窄屏深色截图。证据位于临时目录 `cards-order-fixture-tucKjv`。完整 App 配合已有 Main/Preload 的两次启动尝试未产生窗口,此次 fixture 不作为完整 App 或安装包验收。 + +`npm run typecheck`、`npm run lint`、`git diff --check` 通过;`npm test -- src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/renderer/src/LocalInferencePage.test.tsx src/main/application-settings-store.test.ts` 共 66 项通过,覆盖固定/未常驻项、边界、保存锁定、搜索排序及设置入口。全量 `npm test` 在 60 秒后超时,未取得完整结果。中文改动按 deai-writing 自查清单人工复核。未调用模型、修改模型管理或 GPU 指标,未创建提交。 + +## 2026-09-18:本机推理监控默认不常驻 + +对应 FR-11、US-B 的默认值与迁移场景。共享导航默认值改为本机推理监控不常驻;新安装和旧设置缺失整个导航配置时采用此值,应用仍默认启用,可从应用菜单及管理 Modal 打开。默认可选顺序及知识库、智能心跳的固定入口规则保持不变。沿用存储版本 12,已有常驻 `true`/`false` 均保留;旧默认值与主动固定没有来源标记,无法区分,不强制取消已有常驻。Demo 初始状态和恢复默认同步。 + +验证:设置存储、应用中心、菜单、推理 Modal 共 62 项测试通过,App 入口及设置同步 8 项定向测试通过;`npm run typecheck`、`npm run lint`、Demo 的 `node --check` 和 `git diff --check` 通过。全量 `npm test` 在 60 秒后超时,未取得完整结果。中文改动按 deai-writing 自查清单人工复核。本次未启动 Electron 或调用模型,不涉及 GoodBuddy Agent 或远程执行路径;未创建提交,保留此前未提交的名称调整。 + +## 2026-09-18:统一名称为本机推理监控 + +对应 FR-16、US-F3。导航、应用中心、应用设置、Modal 标题及关闭按钮统一使用“本机推理监控”,英文应用名为 Local Inference Monitor。现行功能文档与 Demo 名称同步;`local-inference`、API、组件名及服务行为保持不变,下方历史记录保留当时称谓。 + +验证:应用菜单、应用中心、Modal 的 27 项测试及 App 入口、设置同步相关的 8 项测试通过;`npm run typecheck`、修改源码 ESLint、Demo 的 `node --check` 和 `git diff --check` 通过。Demo 被 ESLint 既有规则排除。中文改动按 deai-writing 自查清单人工复核,仅同步名称及本条验证事实。本次未运行全量测试或启动 Electron 验收窗口排版。 + ## 2026-09-18:独立推理进程资源采样 对应 FR-16、US-F3。向量 utility process 已按 PID 接入 Electron 真实 CPU 和工作集内存,服务行显示进程范围、PID、百分比及 MiB。ASR 为主进程线程,OCR 为渲染进程 Web Worker,分别显示不能独立统计的原因。采样口径及基线规则见[技术设计](./technical-design.md)。服务 Modal 继续保持单一列表。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index fd844012..f302069f 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 | +| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 | | 版本 | 0.4 | | 日期 | 2026-09-17 | | 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) | @@ -31,7 +31,7 @@ type WorkspaceView = 实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控 面板。侧栏底部已替换为应用中心与独立设置按钮。 -应用中心、可选应用常驻及排序、启用联动、共同设置页和本机推理已接入生产源码;本机资源监控的 +应用中心、四应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的 Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。 行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。 @@ -42,7 +42,7 @@ Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏 | `App.tsx` | 组合主导航、应用中心、固定设置,持有确认配置及页面离开检查 | | `ApplicationMenu.tsx` / `AnchoredMenu.tsx` | 底部向上锚定的已启用应用菜单;关闭后导航,或打开既有管理 Modal;共享几何定位与键盘行为 | | `ApplicationCenter.tsx` | 浏览、搜索内置清单,调用既有页面导航及共同设置入口,编辑常驻和顺序;复用设置 Modal 样式和焦点工具 | -| `ApplicationSettingsView` | 位于 `ApplicationCenter.tsx`,应用中心、应用页面、全局设置共用的应用配置表单 | +| `ApplicationSettingsView` | 位于 `ApplicationCenter.tsx`,由应用中心卡片进入的应用配置表单 | | `RightAssistantSidebar.tsx` | 保留右侧工具 Tab 和“+”目录调用链路 | | `WorkbarShell.tsx` | 继续负责实例策略、Tab、目录和焦点行为 | | `workbar-contracts.ts` | 继续作为工作栏应用 ID 和实例布局的权威契约 | @@ -80,9 +80,9 @@ type BuiltInApplicationDefinition = { ### 3.2 应用与设置导航 应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点 -策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。 -`ApplicationMenu` 按知识库、智能心跳及可选顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。 -中心和应用页的设置操作仅接受 `EditableApplicationId`,全局设置旧入口定位同一个共同页。知识库和智能心跳的管理行仅提供“打开”,原工作区实际配置保留,不添加通用空设置页。 +策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。 +`ApplicationMenu` 按四个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。 +应用中心卡片的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库和智能心跳的管理卡片提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。 共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。 ### 3.3 工作栏选择与创建 @@ -119,26 +119,26 @@ Conversation,无 Conversation 时创建待绑定空实例。同一 Conversatio ```ts type ApplicationNavigationSettings = { - order: EditableApplicationId[] + order: BuiltInApplicationId[] pinned: Record } ``` -`applicationNavigation` 仅保存可选应用顺序和常驻布尔值。默认顺序为 -`magic-notes`、`local-inference`,两项常驻默认 `true`。Zod 验证顺序恰好包含 -两个可选 ID 各一次,`pinned` 仅接受这两个键。知识库和智能心跳不进入导航 schema;Renderer 将它们固定置于可选应用之前,再按启用和常驻过滤可选项。取消常驻或关闭可选应用不删顺序。 +`applicationNavigation` 保存四个应用的完整顺序和两个可选应用的常驻布尔值。默认顺序为 +`knowledge`、`heartbeat`、`magic-notes`、`local-inference`;可选应用常驻分别默认 `true`、`false`。 +权威返回 schema 和更新 schema 均要求四个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 仅接受两个可选应用的完整布尔映射。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库和智能心跳始终启用、常驻,无可写开关键。 按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。 首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。 ### 4.2 与现有开关组合 -可选应用侧栏入口可见条件如下,知识库和智能心跳固定显示: +可选应用侧栏入口可见条件如下,知识库和智能心跳按保存位置始终显示: ```ts const visible = featureEnabled && applicationNavigation.pinned[id] ``` -`magicNotesEnabled` 保留为笔记启用的唯一权威键,`localInferenceEnabled` 为本机推理应用启用键,默认 `true`,不在导航配置再复制 enabled map。心跳没有应用级启用字段,单条计划 enabled 的业务含义和存储不变。关闭可选应用时保留常驻控件和恢复入口。 +`magicNotesEnabled` 保留为笔记启用的唯一权威键,`localInferenceEnabled` 为本机推理监控应用启用键,默认 `true`,不在导航配置再复制 enabled map。心跳没有应用级启用字段,单条计划 enabled 的业务含义和存储不变。关闭可选应用时保留常驻控件和恢复入口。 笔记配置继续使用 `magicNotesShowIncompleteTodoCount`、`magicNoteCommentMode` 和 `magicNoteCommentFormat`,应用中心与笔记页使用同一 schema 和更新链路。未配置的笔记 @@ -161,9 +161,13 @@ Renderer Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore` 的更新队列,读取最新 确认值后合并本次 patch、原子写入并返回完整 `ApplicationSettings`。写入失败不发布目标值。 -存储版本 12 补充可选应用导航和启用字段,仅为缺失笔记字段补新默认值;保留已有 -`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。 -版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled`、`heartbeatEnabled` 已移除;不为它们或旧三项/四项导航草案增加兼容读取器或迁移。 +存储版本 12 补充可选应用导航和启用字段,为缺失字段补默认值;保留已有 +`true`、`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。 +新安装和升级时缺失整个 `applicationNavigation` 均使用共享默认值,本机推理监控启用但不常驻;本次不增加存储版本。已保存的常驻值均保留,无法区分旧默认 `true` 与用户主动固定,不强制改为 `false`。 + +仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失的可选应用。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes]`;完整四项顺序原样保留。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。 + +版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled`、`heartbeatEnabled` 已移除,不为这些开关键增加兼容读取器。 ## 5. 侧栏底部重构 @@ -191,11 +195,11 @@ KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设 ## 6. 应用可用性与知识请求 Renderer 从同一确认快照派生可选应用主导航、中心状态和页面可用性,依赖只包含相应 -字段。keep-alive 页挂载不代表应用仍启用;禁用后在原位置渲染不可用状态和共同设置恢复入口, +字段。keep-alive 页挂载不代表应用仍启用;禁用后在原位置渲染不可用状态, 保留页面草稿及业务数据,阻止新的页面操作。重新启用不触发导航或业务重放。 -`ApplicationAvailability` 隐藏并设置业务内容为 inert,保留挂载的页面实例;恢复入口由 -`ApplicationSettingsNavigation` Context 定位同一中心表单。笔记页面直接消费确认的评论配置, +`ApplicationAvailability` 隐藏并设置业务内容为 inert,保留挂载的页面实例;重新启用从 +应用中心卡片设置进入。笔记页面直接消费确认的评论配置, 不因修改评论方式而重建编辑器。 知识库和智能心跳不经过 `ApplicationAvailability`;会话知识按钮、范围、检索方式和标签继续遵守原知识库规则,心跳计划 enabled 保持不变。 @@ -329,10 +333,10 @@ resourceMonitor.getLocalSnapshot(input: { - 固定入口不受应用启用、常驻和顺序组合影响,应用中心及设置始终可达;左侧没有重复系统工具。 - US-B6:底部菜单向上锚定且无遮罩,只列已启用应用,不受常驻或打开历史影响;点击行关闭菜单并导航,“管理应用”进入既有可搜索 Modal,包含关闭及未常驻应用。 -- US-F3/US-F5:三个入口均打开本机推理 Modal 并保留底层工作区;覆盖嵌套确认的焦点循环、逐层 Escape、提交期间禁止关闭与重复执行、关闭后的入口及后备焦点恢复,以及停用内容后的键盘焦点。 +- US-F3/US-F5:三个入口均打开本机推理监控 Modal 并保留底层工作区;覆盖嵌套确认的焦点循环、逐层 Escape、提交期间禁止关闭与重复执行、关闭后的入口及后备焦点恢复,以及停用内容后的键盘焦点。 - US-B7:常驻、取消常驻、键盘及拖动排序成功后同步,重新开启及重启保留原偏好和顺序。 -- US-B8:知识库和智能心跳始终启用且位置稳定,管理行只有打开操作,心跳计划 enabled 不变;可选应用关闭后的不可用页保留草稿,重新开启不自动导航或重放。 -- US-B9:应用中心与笔记页定位同一笔记表单,启用、常驻、待办数量、评论方式及形式共用配置;全局设置无笔记入口。 +- US-B8:知识库和智能心跳始终启用且常驻,卡片提供打开与排序,不提供启用或常驻开关,心跳计划 enabled 不变;可选应用关闭后的不可用页保留草稿,重新开启不自动导航或重放。 +- US-B9:应用中心卡片进入笔记表单,管理启用、常驻、待办数量、评论方式及形式;笔记页和全局设置无重复入口。 - US-B5:所有入口共享 pending,保存失败保留相同确认值;重开刷新期间锁定修改,较新事件使旧读取失效;结果未知时重读,不自动重发。 - 文件复用不可关闭单实例;选择任一已有终端或浏览器 Tab 保留确切目标,“+”每次创建额外实例。 - 已退出、失败的终端 Tab 不因选中而重建;工具切换不改变主页面,后台状态不抢占 Tab。 @@ -343,8 +347,8 @@ resourceMonitor.getLocalSnapshot(input: { ### 10.2 Main 与契约测试 -- 顺序包含两个可选 ID 各一次;拒绝知识库、智能心跳、重复、缺失、未知 ID 及无效常驻字段;不为未发布的两个旧启用键添加迁移。 -- 未配置布尔项默认开启;已保存的 `false` 和评论单选值经迁移及重启保留。 +- 顺序包含四个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的两个旧启用键添加迁移。 +- 未配置启用及笔记布尔项默认开启,本机推理监控默认不常驻;已保存的常驻值和评论单选值经迁移及重启保留。 - 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。 - 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。 - Store 持久化后才发布变更,工具配置写入使用同一通知链路;知识库和智能心跳不受可选应用偏好影响。 @@ -379,12 +383,12 @@ FR-16 单独覆盖 US-F3 概览去重、未知指标、US-F4 单任务取消与 US-B6 应用中心、US-B7 顺序、US-B8 启用联动、US-B9 笔记迁移,以及 US-F3 概览、 US-F4 取消、US-F5 服务生命周期。PRD、User Stories、配套设计与根 UI 规范已同步入口及持久化规则。 -以下为实施阶段划分,应用中心及本机推理已接入源码,验证状态以进度记录为准。 +以下为实施阶段划分,应用中心及本机推理监控已接入源码,验证状态以进度记录为准。 1. 底部入口使用向上锚定的轻量菜单,通过“管理应用”进入既有整窗 Modal;核对已发布配置的迁移来源。 2. 接入现有内置应用页面与最底部应用中心入口,保留独立设置和固定运行记录入口。 3. 实现常驻、完整顺序及启用契约,迁移时保留明确旧值,通过串行保存与确认快照同步所有界面。 -4. 迁移魔法笔记共同设置,保留知识库、智能心跳固定入口及原工作区配置、单条计划 enabled,验证可选应用关闭和重新开启。 +4. 迁移魔法笔记共同设置,保留知识库、智能心跳始终可用及原工作区配置、单条计划 enabled,验证可选应用关闭和重新开启。 5. 验证右侧现有文件、终端、浏览器 Tab 的确切目标与“+”额外实例行为,不实现左侧重复工具条。 6. 单独实现资源快照契约、Main 采集服务、Preload API 与右侧 `resources` 单实例面板。 7. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index 88e773b3..bb4bc1d0 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -4,14 +4,14 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 | +| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 | | 版本 | 0.4 | | 日期 | 2026-09-17 | | 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) | | 功能逻辑 | [功能逻辑设计](./logic-design.md) | | 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) | -本文定义已接入的应用中心、共同设置和本机推理界面,以及待实现的右侧资源监控界面。 +本文定义已接入的应用中心、共同设置和本机推理监控界面,以及待实现的右侧资源监控界面。 行为以[功能逻辑](./logic-design.md)为准,需求保留 FR-1 至 FR-16 与既有 US 编号。 根 UI 规范已同步应用中心与工具入口规则;共享控件、主题、设置 Modal 与工作栏布局遵守根规范。 @@ -25,9 +25,7 @@ 搜索 当前对话 -知识库(固定显示) -智能心跳(固定显示,保留建议徽标) -可选应用(魔法笔记、本机推理,按保存顺序,仅显示启用且常驻项) +应用(按保存顺序;知识库、智能心跳始终显示,可选应用仅显示启用且常驻项) 运行记录 最近对话 @@ -38,7 +36,7 @@ ``` “当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、 -知识库、智能心跳及本轮追加的本机推理,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`; +知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`; 排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。 ## 2. 底部应用中心入口 @@ -71,44 +69,43 @@ [图标] 知识库 [图标] 智能心跳 1 [图标] 魔法笔记 3 -[图标] 本机推理 +[图标] 本机推理监控 ``` -取消常驻或关闭可选应用后不留下空白。知识库、智能心跳固定在当前对话之后,始终启用并显示,不受可选顺序影响。运行记录始终位于应用区之后。 -编辑常驻顺序提供拖动及“上移”“下移”按钮,首尾无可移动目标时禁用对应按钮;两种方式 -提交相同可选应用顺序。知识库、智能心跳及固定系统入口不参与拖动,也不作为排序目标。保存成功后更新,重启保留。 +取消常驻或关闭可选应用后不留下空白。知识库、智能心跳始终启用并显示,位置遵守共享应用顺序。运行记录始终位于应用区之后。 +四个应用均在管理卡片上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。 -取消常驻不关闭当前页面或本机推理 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。 +取消常驻不关闭当前页面或本机推理监控 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。 主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。 ## 4. 应用中心与共同设置 -底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单依次列知识库、智能心跳和按保存顺序排列的已启用可选应用;不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。 +底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。 菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。 管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置: -应用管理采用桌面双列卡片,窗口宽度不超过 `720px` 时改为单列。Modal 最大宽度 `960px`, +应用管理采用 `auto-fit/minmax` 网格,卡片最小宽度 `260px`,不足时收缩为容器宽度;按 Modal 可用宽度排列三列及以上、双列或单列。Modal 最大宽度保持 `960px`,当前最大宽度可容纳三列, 高度随内容增长并受窗口限制。卡片显示图标、名称、状态和用途,底部放置操作;名称旁区分 -“固定入口”和“可选 · 已启用/已关闭”。长名称可换行,正文与控件均不可横向溢出。 +“始终显示”(英文 `Always shown`)和“可选 · 已启用/已关闭”。长名称可换行,正文与控件均不可横向溢出。 ```text 应用中心 [搜索内置应用] -知识库 固定入口 [打开] -智能心跳 固定入口 [打开] -魔法笔记 可选 · 已启用 [打开] [设置] -本机推理 可选 · 已启用 [打开] [设置] +知识库 始终显示 [打开] [上移] [下移] +智能心跳 始终显示 [打开] [上移] [下移] +魔法笔记 可选 · 已启用 [打开] [设置] [上移] [下移] +本机推理监控 可选 · 已启用 [打开] [设置] [上移] [下移] ``` 已启用但未常驻的应用可以直接打开,打开不自动常驻。关闭的应用仍能被搜索和配置, -保留禁用的“打开”按钮,可通过“设置”重新开启。启用、常驻及上下移动放在设置详情中;无搜索词且未锁定修改时,可选卡片仍支持拖动排序。无搜索结果显示清除搜索入口。 +保留禁用的“打开”按钮,可通过“设置”重新开启。启用、常驻放在设置详情中;每张卡片底部提供成组的上下箭头,与打开、设置操作按空间换行。箭头使用共享图标按钮,名称和提示包含方向及应用名;目标取完整保存顺序中的相邻应用,仅首尾无目标或修改锁定时禁用对应箭头。无搜索词且未锁定修改时,所有卡片均支持拖动排序,包括关闭或未常驻的应用;搜索时禁用拖动,箭头仍按完整保存顺序操作,未命中的应用保留在顺序中。无搜索结果显示清除搜索入口。 首期不展示商店页签、推荐、安装按钮、市场 URL 或 YAML 操作,系统工具也不在此列出。 ### 4.1 共同应用设置页 -魔法笔记、本机推理的应用管理和应用页面入口定位到同一应用设置页,复用同一个表单及配置。知识库和智能心跳在管理 Modal 中仅提供“打开”,不提供启用、常驻、排序或通用空设置;原工作区实际配置入口保留。 +魔法笔记、本机推理监控的通用应用设置统一从应用中心卡片的“设置”进入;应用页面不再提供重复快捷入口。本机推理监控保留“模型与连接设置”。知识库和智能心跳在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区实际配置入口保留。 共同页遵守[设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心),不为每个入口建立一套设置。 全局设置移除魔法笔记页签及跳转入口。旧“显示魔法笔记入口”绑定业务启用值,迁为“启用应用”并保留原值; “常驻左侧菜单”使用独立配置,只有已发布的独立显隐偏好才映射到常驻。 @@ -121,12 +118,12 @@ | AI 评论方式 | 即时/保存后自动/保存后手动,单选 | | AI 评论形式 | 长评 + 要点/长评/要点,单选 | -二元配置使用共享 Switch 和 `role="switch"`;评论选项使用共享单选控件。未配置布尔项 -默认开启,评论默认值沿用既有值,已有明确的关闭或单选选择保留。启用与常驻不可合并。 +二元配置使用共享 Switch 和 `role="switch"`;评论选项使用共享单选控件。本机推理监控 +默认启用但不常驻,其余未配置布尔项默认开启;评论默认值沿用既有值,已有保存的选择保留。启用与常驻不可合并。 设置详情最大宽度 `640px`,紧凑标题栏包含返回图标、应用名称、用途及关闭按钮。进入详情和 返回卡片时正文滚动位置重置到顶部;进入详情聚焦关闭按钮,返回后聚焦搜索框。 共同设置表单使用共享 Switch,相关字段分组,组间使用弱分隔线;说明沿用正文字号。 -评论分段控件按内容占宽,窄窗口中标签与选项上下排列;上下移动按钮位于详情表单下方。 +评论分段控件按内容占宽,窄窗口中标签与选项上下排列;详情不提供排序控件。 智能心跳单条计划 CRUD 及 enabled 仍由其业务页面负责,没有应用级心跳启用开关。 所有入口共享保存中状态。串行保存期间禁用相关启用、常驻、排序及笔记配置控件,保留 @@ -135,7 +132,7 @@ ### 4.2 关闭后的页面与会话 -关闭当前可选应用成功后,原页面或本机推理 Modal 显示“应用已关闭”和“前往应用设置”,保留草稿和内容状态, +关闭当前可选应用成功后,原页面或本机推理监控 Modal 显示“应用已关闭”,重新启用通过应用中心卡片设置完成;本机推理监控需先关闭 Modal。保留草稿和内容状态, 暂停该页面的新业务操作。重新开启后恢复可用性,不自动跳到已离开的应用页或重放任务。 知识库和智能心跳不显示应用关闭状态;会话知识控件、历史引用、工作区配置及心跳计划遵守原功能规则。可选应用的已有请求和后台任务继续按功能生命周期展示,详细边界见[关闭应用与恢复](./logic-design.md#32-关闭应用与恢复)。 @@ -255,10 +252,10 @@ FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在 ### 9.1 本机推理管理 -FR-16 的 `local-inference` 入口已纳入本轮应用清单,可常驻主导航。从常驻入口、应用菜单和管理 Modal 的“打开”均进入同一本机推理 Modal,关闭来源菜单或管理 Modal,保留底层工作区、项目、会话和草稿。其他内置应用仍打开主内容区页面。 +FR-16 的 `local-inference` 入口已纳入本轮应用清单,可常驻主导航。从常驻入口、应用菜单和管理 Modal 的“打开”均进入同一本机推理监控 Modal,关闭来源菜单或管理 Modal,保留底层工作区、项目、会话和草稿。其他内置应用仍打开主内容区页面。 Modal 与真实执行模块已接入;支持清单及验证范围分别见技术设计和实施进度。 复用 `custom-task-dialog` 的整窗 body Portal、遮罩、背景隔离及浏览器遮挡机制。主 Modal 最大宽度 `960px`,高度随内容增长,最大高度为 `min(720px, 100dvh - 2 × --space-6)`;紧凑标题栏和关闭按钮固定在顶部,正文独立滚动。直接显示服务列表,没有页签或任务历史。工具栏显示“每 5 秒刷新 · CPU 100% = 一个逻辑核心”、模型与连接设置、刷新状态,窄屏可换行。服务行显示“服务进程 PID … · CPU …% · 工作集 … MiB”,预热时显示“正在建立采样基线”;共享进程的 worker 在该行显示无法独立统计的原因。 -初始焦点进入“关闭本机推理”,Tab/Shift+Tab 在当前 Modal 内循环。没有影响确认且没有操作提交时,关闭按钮或 Escape 关闭 Modal;点击遮罩不关闭。关闭后优先恢复仍可用的原触发元素,入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关。 +初始焦点进入“关闭本机推理监控”,Tab/Shift+Tab 在当前 Modal 内循环。没有影响确认且没有操作提交时,关闭按钮或 Escape 关闭 Modal;点击遮罩不关闭。关闭后优先恢复仍可用的原触发元素,入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关。 服务采用弱分隔列表;名称使用区块标题字号,状态及引擎元数据使用 辅助字号,状态与名称同组并与操作区分开,原因与操作说明使用正文字号。桌面操作独立位于右侧; 窗口宽度不超过 `720px` 时移到正文下方并左对齐,保留完整状态和原因。 diff --git a/docs/features/application-tool-navigation/user-stories.md b/docs/features/application-tool-navigation/user-stories.md index 5b28c974..d5cda893 100644 --- a/docs/features/application-tool-navigation/user-stories.md +++ b/docs/features/application-tool-navigation/user-stories.md @@ -4,7 +4,7 @@ | 项目 | 内容 | | --- | --- | -| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 | +| 状态 | 应用中心与本机推理监控已接入源码,整体验收待完成 | | 版本 | 0.4 | | 日期 | 2026-09-17 | | 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) | @@ -47,7 +47,7 @@ ## 3. Epic B:管理应用入口 -本节的启用、常驻、排序和共同设置仅适用于魔法笔记、本机推理;知识库和智能心跳始终启用并固定显示。原知识库/智能心跳禁用场景已退役,保留故事 ID,并在 US-B6、US-B8 中以固定可用不变量替换验收。 +本节的启用、常驻和共同设置仅适用于魔法笔记、本机推理监控;四个应用均可排序,知识库和智能心跳始终启用并常驻。原知识库/智能心跳禁用场景已退役,保留故事 ID,并在 US-B6、US-B8 中以始终可用不变量替换验收。 ### US-B1 识别完整应用 @@ -55,12 +55,12 @@ 验收: -- Given 固定应用,或已启用且常驻的可选应用 +- Given 始终显示的应用,或已启用且常驻的可选应用 - When 用户打开主侧栏 - Then 应用位于“当前对话”和“运行记录”之间 -- And 点击知识库、智能心跳或魔法笔记在主内容区打开对应一级页面;本机推理打开保留底层工作区的 Modal,验收见 US-F3 +- And 点击知识库、智能心跳或魔法笔记在主内容区打开对应一级页面;本机推理监控打开保留底层工作区的 Modal,验收见 US-F3 - And 原有待办或建议徽标按已保存的显示设置继续显示 -- And 当前对话之后依次固定显示知识库、智能心跳,再显示可选应用;两个固定应用不参与启用、常驻或排序 +- And 当前对话之后按保存顺序显示应用;知识库和智能心跳始终显示,可调整位置,无启用或常驻开关 ### US-B2 取消低频应用常驻 @@ -114,16 +114,16 @@ - Given 用户位于任意一级页面,包括全部可选应用均已关闭的情况 - When 点击侧栏底部应用中心 -- Then 向上打开轻量锚定菜单,无模态遮罩;只列已启用应用,包括固定知识库、智能心跳和已启用的可选应用,不按打开历史或常驻筛选 +- Then 向上打开轻量锚定菜单,无模态遮罩;按保存顺序列出已启用应用,包括知识库、智能心跳和已启用的可选应用,不按打开历史或常驻筛选 - When 点击菜单中的应用行 -- Then 菜单关闭;知识库、智能心跳和魔法笔记经既有离开检查在主内容区打开,本机推理打开保留底层工作区的 Modal,不自动常驻 +- Then 菜单关闭;知识库、智能心跳和魔法笔记经既有离开检查在主内容区打开,本机推理监控打开保留底层工作区的 Modal,不自动常驻 - When 点击菜单底部“管理应用” - Then 菜单关闭并打开既有管理 Modal,可按名称或用途搜索全部内置应用,包含未常驻及已关闭的可选应用 -- And 知识库和智能心跳行只提供“打开”,没有通用设置、启用、常驻或排序控件;原工作区实际配置及心跳单条计划 enabled 不变 +- And 知识库和智能心跳卡片提供“打开”和排序,没有通用设置、启用或常驻开关;原工作区实际配置及心跳单条计划 enabled 不变 - And 首期没有商店、推荐、安装、市场 URL、YAML 操作或系统工具入口 - Given 应用已启用但未常驻 - When 用户选择打开 -- Then 本机推理打开 Modal,底层内容无需离开;其余应用经既有未保存内容离开检查进入页面,不自动常驻 +- Then 本机推理监控打开 Modal,底层内容无需离开;其余应用经既有未保存内容离开检查进入页面,不自动常驻 - Given 应用已关闭 - When 用户在管理 Modal 中找到该应用 - Then 显示关闭状态,保留禁用的“打开”按钮,可通过“设置”进入共同设置重新开启 @@ -139,10 +139,14 @@ 验收: - Given 首次使用且未配置导航偏好 -- Then 知识库、智能心跳固定在前,可选应用默认均常驻,顺序为魔法笔记、本机推理;只按启用与常驻过滤可选项 +- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控;魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;侧栏只按启用与常驻过滤可选项 - When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功 -- Then 仅可选应用顺序变化,知识库、智能心跳、当前对话、运行记录及底部固定入口不参与排序,重启后恢复保存结果 -- And 管理 Modal 编辑两个可选应用的完整顺序,上下移动以该顺序的相邻项为目标,固定应用不作为移动目标 +- Then 卡片、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果 +- And 每张卡片均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件 +- And 搜索时箭头仍按完整顺序移动,保留未命中应用,拖动禁用;清除搜索后显示完整保存结果 +- Given 旧设置仅保存本机推理监控、魔法笔记的顺序 +- When 升级后读取设置 +- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值 - Given 应用已关闭或取消常驻 - When 用户重新开启或常驻 - Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好 @@ -164,8 +168,8 @@ - When 用户关闭全部可选应用或修改其常驻与顺序 - Then 知识库和智能心跳始终启用,侧栏位置稳定,页面可打开;知识控件及关联保持可用,请求遵守知识库自身规则,心跳单条计划 enabled 原值不变 - And 原“禁用知识库/智能心跳后隐藏业务入口”的验收由上述不变量替换;不存在这两个应用的应用级关闭状态或恢复设置页 -- Given 关闭的应用页面或本机推理 Modal 仍打开 -- Then 原位置显示“应用已关闭”和前往应用设置的恢复入口,保留草稿及页面状态,阻止新业务操作而不强制跳转 +- Given 关闭的应用页面或本机推理监控 Modal 仍打开 +- Then 原位置显示“应用已关闭”,保留草稿及页面状态,阻止新业务操作而不强制跳转;用户从应用中心卡片设置重新启用,监控 Modal 需先关闭 - When 用户重新开启并保存成功 - Then 仍停留的页面恢复可用;已离开时不自动导航,不重放或补发旧请求和任务 - And 保留原常驻及顺序,未常驻应用不自动常驻 @@ -174,17 +178,17 @@ ### US-B9 迁移魔法笔记共同设置 -作为魔法笔记用户,我希望从各入口编辑同一份设置,并在升级后保留已有选择。 +作为魔法笔记用户,我希望在应用中心统一管理笔记设置,并在升级后保留已有选择。 验收: -- Given 用户从应用中心或笔记页面进入笔记设置 -- Then 定位到同一共同设置页,使用同一表单和配置,不保留重复控件或“显示魔法笔记入口”开关 +- Given 用户从应用中心的魔法笔记卡片进入设置 +- Then 显示笔记设置表单;笔记页面不提供重复入口,也不保留“显示魔法笔记入口”开关 - And 全局设置不再显示魔法笔记页签或跳转入口,已有配置值和业务数据保持不变 - And 启用、常驻、显示未完成待办数量分别控制业务、导航和徽标;隐藏数量不修改待办 - Given 升级前已有明确保存的布尔值和评论选择 - When 迁移配置并重启 -- Then 保留已有 `false`、评论单选值及业务数据,仅未配置的应用启用、常驻和笔记相关布尔项默认 `true` +- Then 保留已有 `true`、`false`、评论单选值及业务数据;缺失导航配置时,本机推理监控默认不常驻,魔法笔记默认常驻;未配置的应用启用和笔记相关布尔项默认 `true` - And 旧入口开关若实际控制笔记启用,保留其启用含义和原值,不将其改作常驻 - Given AI 评论方式或评论形式未配置 - Then 分别默认“即时”和“长评 + 要点”,均为单选,不同时启用所有模式 @@ -328,7 +332,7 @@ - When 按 Escape 关闭菜单 - Then 焦点返回应用中心入口;Tab 关闭菜单后继续正常焦点顺序,点击外部或焦点离开也关闭 - When 选择“管理应用” -- Then 关闭菜单并进入既有 Modal,焦点进入搜索;卡片的打开、设置及详情中的启用、常驻、排序控件按正常 Tab 顺序可达,不伪装成单个菜单项;进入详情及返回时滚动归顶,返回后聚焦搜索 +- Then 关闭菜单并进入既有 Modal,焦点进入搜索;卡片的打开、设置、排序及详情中的启用、常驻控件按正常 Tab 顺序可达,不伪装成单个菜单项;进入详情及返回时滚动归顶,返回后聚焦搜索 - When 通过键盘修改常驻或顺序 - Then Switch 使用共享开关语义并支持 Space,上移和下移按钮名称包含应用名,结果通过 polite 状态区播报,失败保留焦点 - Given 用户进入右侧工作栏 @@ -406,7 +410,7 @@ ## 7. 后续需求场景 -US-F1、US-F2 为后续市场计划;US-F3 至 US-F5 的本机推理已接入源码,当前支持边界见技术设计,验证范围见实施进度。 +US-F1、US-F2 为后续市场计划;US-F3 至 US-F5 的本机推理监控已接入源码,当前支持边界见技术设计,验证范围见实施进度。 ### US-F1 连接组织应用市场 @@ -446,7 +450,7 @@ US-F1、US-F2 为后续市场计划;US-F3 至 US-F5 的本机推理已接入 对应 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。 - Given 用户使用依赖本机模型的应用 -- When 从常驻入口、应用菜单或管理 Modal 的“打开”进入“本机推理” +- When 从常驻入口、应用菜单或管理 Modal 的“打开”进入“本机推理监控” - Then 均打开同一管理 Modal,来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话和未提交草稿保持不变 - And 直接显示服务列表,不出现任务页签、任务历史或外部连接;知识库、智能心跳和魔法笔记仍打开主内容区页面 - And 初始焦点进入关闭按钮,背景不可交互,Tab/Shift+Tab 在 Modal 内循环;标题与关闭按钮保持可见,正文独立滚动 diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index d780a09b..069c5826 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -44,6 +44,50 @@ afterEach(async () => { }) describe('ApplicationSettingsStore', () => { + it.each([ + { order: ['local-inference', 'magic-notes'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] }, + { order: ['local-inference'], expected: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] }, + { order: ['magic-notes', 'knowledge'], expected: ['heartbeat', 'magic-notes', 'knowledge', 'local-inference'] }, + { order: [], expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] }, + { order: undefined, expected: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] }, + { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], expected: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] }, + ])('normalizes stored order $order without changing existing relative order', async ({ order, expected }) => { + const { filePath, store } = await createStore() + await writeFile(filePath, JSON.stringify({ + ...defaultApplicationSettings, version: 12, lastSeenReleaseNotesVersion: null, + magicNotesEnabled: false, applicationNavigation: { order, pinned: { 'magic-notes': false } }, + })) + const settings = await store.get() + expect(settings.warnings).toBeUndefined() + expect(settings.magicNotesEnabled).toBe(false) + expect(settings.applicationNavigation).toEqual({ order: expected, pinned: { 'magic-notes': false, 'local-inference': false } }) + expect(applicationSettingsSchema.parse(settings)).toEqual(settings) + await store.update({ checkUpdatesOnStartup: false }) + expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation) + expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(settings.applicationNavigation) + }) + + it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => { + const { store, filePath } = await createStore() + const applicationNavigation = { + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], + pinned: { 'magic-notes': false, 'local-inference': false }, + } + const changed = vi.fn() + store.onChanged(changed) + const saved = await store.update({ applicationNavigation }) + expect(applicationSettingsSchema.parse(saved).applicationNavigation).toEqual(applicationNavigation) + expect(changed).toHaveBeenCalledExactlyOnceWith(saved) + const contents = await readFile(filePath, 'utf8') + for (const order of [[], ['local-inference', 'magic-notes'], ['knowledge', 'knowledge', 'magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) { + expect(applicationSettingsSchema.safeParse({ ...saved, applicationNavigation: { ...applicationNavigation, order } }).success).toBe(false) + await expect(store.update({ applicationNavigation: { ...applicationNavigation, order } })).rejects.toThrow() + } + expect(await readFile(filePath, 'utf8')).toBe(contents) + expect(changed).toHaveBeenCalledTimes(1) + expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(applicationNavigation) + }) + it('keeps the confirmed snapshot when an atomic save fails', async () => { const { filePath, store } = await createStore() const confirmed = await store.get() @@ -55,14 +99,27 @@ describe('ApplicationSettingsStore', () => { expect(changed).not.toHaveBeenCalled() }) - it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => { + it.each([11, 12])('defaults missing navigation and Notes fields in version %s without resetting unrelated values', async (version) => { const { filePath, store } = await createStore() - const legacy: Record = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false } + const legacy: Record = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false } for (const key of ['applicationNavigation', 'localInferenceEnabled', 'magicNotesEnabled', 'magicNotesShowIncompleteTodoCount']) delete legacy[key] await writeFile(filePath, JSON.stringify(legacy), 'utf8') expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false }) + expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation }) expect(await createApplicationSettingsStore(filePath).get()).toEqual(await store.get()) }) + it.each([true, false])('preserves a saved local inference pin of %s through migration, updates and reload', async (pinned) => { + const { filePath, store } = await createStore() + const applicationNavigation = { + order: ['local-inference', 'magic-notes'], + pinned: { 'magic-notes': false, 'local-inference': pinned } + } + await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8') + const normalized = { ...applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] } + expect((await store.get()).applicationNavigation).toEqual(normalized) + await store.update({ checkUpdatesOnStartup: false }) + expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized) + }) it('migrates released settings without replacing explicit false values or comment choices', async () => { const { filePath, store } = await createStore() const legacy: Record = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' } @@ -82,8 +139,9 @@ describe('ApplicationSettingsStore', () => { const navigation = defaultApplicationSettings.applicationNavigation for (const invalid of [ { ...navigation, order: navigation.order.slice(1) }, - { ...navigation, order: ['magic-notes', 'magic-notes'] }, - { ...navigation, order: ['unknown', 'local-inference'] }, + { ...navigation, order: ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'] }, + { ...navigation, order: ['knowledge', 'heartbeat', 'unknown', 'local-inference'] }, + { ...navigation, order: ['magic-notes', 'local-inference'] }, { ...navigation, order: ['knowledge', 'local-inference'] }, { ...navigation, order: ['heartbeat', 'local-inference'] }, { ...navigation, pinned: { ...navigation.pinned, heartbeat: true } }, @@ -97,6 +155,7 @@ describe('ApplicationSettingsStore', () => { const { directory, store } = await createStore() await expect(store.get()).resolves.toEqual(defaultApplicationSettings) + expect(await store.get()).toMatchObject({ localInferenceEnabled: true, applicationNavigation: defaultApplicationSettings.applicationNavigation }) await expect(readdir(directory)).resolves.toEqual([]) }) diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index f73bc5f4..953ada99 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -4,6 +4,8 @@ import { z } from 'zod' import { applicationSettingsSchema, applicationSettingsUpdateSchema, + applicationNavigationSchema, + builtInApplicationIds, defaultApplicationNavigation, type ApplicationSettings } from '../shared/application-settings-contracts' @@ -144,6 +146,20 @@ const versionTenStoredApplicationSettingsSchema = const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ + // Stored partial orders are upgraded here; public settings writes remain strict. + applicationNavigation: applicationNavigationSchema.extend({ + order: z.array(z.enum(builtInApplicationIds)) + .refine(ids => new Set(ids).size === ids.length, 'Duplicate application') + .default([]) + .transform(ids => [ + ...(['knowledge', 'heartbeat'] as const).filter(id => !ids.includes(id)), + ...ids, + ...defaultApplicationNavigation.order.filter(id => id !== 'knowledge' && id !== 'heartbeat' && !ids.includes(id)) + ]), + pinned: applicationNavigationSchema.shape.pinned.partial() + .default({}) + .transform(pinned => ({ ...defaultApplicationNavigation.pinned, ...pinned })) + }).default(defaultApplicationNavigation), version: z.literal(CURRENT_SETTINGS_VERSION), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 2114f838..ec772362 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -13267,7 +13267,38 @@ describe("App", () => { } }); - it('refreshes externally changed navigation before editing and keeps Knowledge in its fixed slot', async () => { + it('shares card reorder saves with the launcher and filtered sidebar across reopening', async () => { + const updates = api.updates! + await updates.updateSettings({ magicNotesEnabled: true }) + render() + await screen.findByRole('button', { name: '魔法笔记' }) + fireEvent.click(screen.getByRole('button', { name: '应用中心' })) + fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) + const move = screen.getByRole('button', { name: '下移 知识库' }) + await waitFor(() => expect(move).toBeEnabled()) + fireEvent.click(move) + await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ + '智能心跳', '知识库', '魔法笔记', '本机推理监控', + ])) + await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled()) + const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article') + fireEvent.dragStart(cards[3]!) + fireEvent.drop(cards[1]!) + await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ + '智能心跳', '本机推理监控', '知识库', '魔法笔记', + ])) + expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes']) + fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) + expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ + '对话', '智能心跳', '知识库', '魔法笔记', '运行记录', + ]) + fireEvent.click(screen.getByRole('button', { name: '应用中心' })) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['智能心跳', '本机推理监控', '知识库', '魔法笔记', '管理应用']) + fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) + expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['智能心跳', '本机推理监控', '知识库', '魔法笔记']) + }) + + it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => { const updates = api.updates! const original = await updates.getSettings() render() @@ -13276,24 +13307,25 @@ describe("App", () => { ...original, applicationNavigation: { ...original.applicationNavigation, - order: ['local-inference', 'magic-notes'] as const, + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as const, + pinned: { ...original.applicationNavigation.pinned, 'local-inference': true }, }, } vi.mocked(updates.getSettings).mockResolvedValue({ ...external, applicationNavigation: { ...external.applicationNavigation, order: [...external.applicationNavigation.order] } }) fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) expect(pin).toBeDisabled() await waitFor(() => expect(pin).not.toBeDisabled()) const nav = screen.getByRole('navigation', { name: '主导航' }) expect(within(nav).getAllByRole('button').map(button => button.textContent)).toEqual([ - '对话', '知识库', '智能心跳', '本机推理', '运行记录', + '对话', '本机推理监控', '智能心跳', '知识库', '运行记录', ]) fireEvent.click(pin) await waitFor(() => expect(updates.updateSettings).toHaveBeenCalledWith({ applicationNavigation: { - order: ['local-inference', 'magic-notes'], + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], pinned: { ...original.applicationNavigation.pinned, 'local-inference': false }, }, })) @@ -13301,6 +13333,7 @@ describe("App", () => { it.each(['read', 'mutation'] as const)('keeps a settings event authoritative over a stale %s reply', async (operation) => { const updates = api.updates! + await updates.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } }) const original = await updates.getSettings() let changed!: Parameters[0] vi.mocked(updates.onSettingsChanged).mockImplementation(listener => { @@ -13313,7 +13346,7 @@ describe("App", () => { if (operation === 'read') vi.mocked(updates.getSettings).mockReturnValueOnce(reply.promise) fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) if (operation === 'mutation') { await waitFor(() => expect(pin).not.toBeDisabled()) @@ -13323,7 +13356,7 @@ describe("App", () => { const external: ApplicationSettings = { ...original, applicationNavigation: { - order: ['local-inference', 'magic-notes'], + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'], pinned: { ...original.applicationNavigation.pinned, 'local-inference': false }, }, } @@ -13332,7 +13365,7 @@ describe("App", () => { await act(async () => reply.resolve(original)) await waitFor(() => expect(pin).not.toBeDisabled()) expect(pin).not.toBeChecked() - expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument() + expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument() expect(within(screen.getByRole('navigation', { name: '主导航' })).getByRole('button', { name: '智能心跳' })).toBeInTheDocument() fireEvent.click(pin) expect(updates.updateSettings).toHaveBeenLastCalledWith({ @@ -13340,6 +13373,14 @@ describe("App", () => { }) }) + it('keeps Magic Notes free of a generic application settings shortcut', async () => { + await api.updates!.updateSettings({ magicNotesEnabled: true }) + render() + fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' })) + await waitFor(() => expect(document.querySelector('.magic-notes-page')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() + }) + it('keeps fixed apps available when application settings cannot be loaded', async () => { vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable')) render() @@ -13353,8 +13394,11 @@ describe("App", () => { }) it.each(['sidebar', 'menu', 'center'])('opens local inference from %s as a modal preserving the workspace', async (entry) => { + if (entry === 'sidebar') await api.updates!.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } }) render() - const nav = await screen.findByRole('button', { name: '本机推理' }) + await waitFor(() => expect(api.updates!.getSettings).toHaveBeenCalled()) + const nav = entry === 'sidebar' ? await screen.findByRole('button', { name: '本机推理监控' }) : screen.getByRole('button', { name: '应用中心' }) + if (entry !== 'sidebar') expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual(['对话', '知识库', '智能心跳', '运行记录']) fireEvent.click(screen.getByRole('button', { name: '知识库' })) const workspace = await screen.findByLabelText('知识工作区') if (entry === 'sidebar') { @@ -13362,19 +13406,19 @@ describe("App", () => { fireEvent.click(nav) } else { fireEvent.click(screen.getByRole('button', { name: '应用中心' })) - if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' })) + if (entry === 'menu') fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) else { fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - const item = screen.getByRole('button', { name: '本机推理 应用设置' }).closest('article')! + const item = screen.getByRole('button', { name: '本机推理监控 应用设置' }).closest('article')! fireEvent.click(within(item).getByRole('button', { name: '打开' })) } } - const dialog = screen.getByRole('dialog', { name: '本机推理' }) + const dialog = screen.getByRole('dialog', { name: '本机推理监控' }) expect(workspace).toBeInTheDocument() expect(workspace.closest('.app-shell')).toHaveProperty('inert', true) expect(nav).not.toHaveAttribute('aria-current', 'page') expect(dialog.closest('main')).toBeNull() - const close = within(dialog).getByRole('button', { name: '关闭本机推理' }) + const close = within(dialog).getByRole('button', { name: '关闭本机推理监控' }) expect(close).toHaveFocus() fireEvent.click(close) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() @@ -13384,16 +13428,17 @@ describe("App", () => { }) it('opens an unpinned app and recovers a disabled current page without remounting it', async () => { + await api.updates!.updateSettings({ applicationNavigation: { ...defaultApplicationNavigation, pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true } } }) let changed!: Parameters['onSettingsChanged']>[0] vi.mocked(api.updates!.onSettingsChanged).mockImplementation(listener => { changed = listener return vi.fn() }) render() - const inferenceNav = await screen.findByRole('button', { name: '本机推理' }) + const inferenceNav = await screen.findByRole('button', { name: '本机推理监控' }) fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) await waitFor(() => expect(pin).not.toBeDisabled()) fireEvent.click(pin) @@ -13403,21 +13448,30 @@ describe("App", () => { expect(trigger).toHaveFocus() fireEvent.click(trigger) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' })) + fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) const workspace = await screen.findByRole('region', { name: '本机推理服务' }) - expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理' })).not.toBeInTheDocument() + expect(within(screen.getByRole('navigation', { name: '主导航' })).queryByRole('button', { name: '本机推理监控' })).not.toBeInTheDocument() await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false }))) expect(screen.getByText('应用已关闭')).toBeInTheDocument() expect(workspace).toBeInTheDocument() expect(workspace.closest('.local-inference-workspace')).toHaveAttribute('hidden') - fireEvent.click(screen.getByRole('button', { name: '应用设置' })) + expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() + await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: true }))) + expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace) + expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden') + expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false) + await act(async () => changed(await api.updates!.updateSettings({ localInferenceEnabled: false }))) + fireEvent.click(screen.getByRole('button', { name: '关闭本机推理监控' })) + fireEvent.click(screen.getByRole('button', { name: '应用中心' })) + fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeDisabled()) fireEvent.click(screen.getByRole('switch', { name: '启用应用' })) await waitFor(() => expect(screen.getByRole('switch', { name: '启用应用' })).toBeChecked()) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) - expect(screen.getByRole('region', { name: '本机推理服务' })).toBe(workspace) - expect(workspace.closest('.local-inference-workspace')).not.toHaveAttribute('hidden') - expect((await api.updates!.getSettings()).applicationNavigation.pinned['local-inference']).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '应用中心' })) + fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) + expect(await screen.findByRole('region', { name: '本机推理服务' })).toBeVisible() }) it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => { @@ -13426,14 +13480,14 @@ describe("App", () => { fireEvent.click(screen.getByRole('button', { name: '应用中心' })) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) const updates = api.updates! - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) await waitFor(() => expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeDisabled()) vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost')) vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable')) fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' })) await screen.findByText('无法确认保存结果,请重新读取后再修改。') const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) - expect(pin).toBeChecked() + expect(pin).not.toBeChecked() expect(pin).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: '重新读取' })) await waitFor(() => expect(pin).not.toBeDisabled()) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index eb7de6b9..33f1c1f3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,7 +4,6 @@ import { ApplicationMenu } from './ApplicationMenu'; import { ApplicationAvailability, ApplicationCenter, - ApplicationSettingsNavigation, applicationDefinitions, isApplicationEnabled, } from "./ApplicationCenter"; @@ -12,7 +11,6 @@ import { defaultApplicationNavigation, type ApplicationSettings, type ApplicationSettingsUpdate, - type EditableApplicationId, } from "../../shared/application-settings-contracts"; import type { ImageOperation } from "../../shared/image-generation-contracts"; import { @@ -2421,7 +2419,6 @@ function App(): React.JSX.Element { const [applicationCenterOpen, setApplicationCenterOpen] = useState(false); const [applicationMenuOpen, setApplicationMenuOpen] = useState(false); const applicationMenuTriggerRef = useRef(null); - const [applicationSettingsTarget, setApplicationSettingsTarget] = useState(); const [applicationSettings, setApplicationSettings] = useState(); const [applicationSettingsPending, setApplicationSettingsPending] = useState(false); const [applicationSettingsUnconfirmed, setApplicationSettingsUnconfirmed] = useState(false); @@ -2603,7 +2600,7 @@ function App(): React.JSX.Element { ] = useState(true); const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0); const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation; - const visibleApplications = useMemo(() => ['knowledge' as const, 'heartbeat' as const, ...applicationNavigation.order].filter(id => + const visibleApplications = useMemo(() => applicationNavigation.order.filter(id => id === 'knowledge' || id === 'heartbeat' || (applicationNavigation.pinned[id] && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id))) ), [applicationNavigation, applicationSettings, magicNotesEnabled]); const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => { @@ -2661,19 +2658,6 @@ function App(): React.JSX.Element { setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0); } }, [applicationSettingsUnconfirmed, applyApplicationSettings, t]); - const openApplicationSettings = useCallback((id: EditableApplicationId): void => { - const open = (): void => { - setApplicationSettingsTarget(id); - setApplicationMenuOpen(false); - setApplicationCenterOpen(true); - void reloadApplicationSettings(); - }; - if (settingsOpenRef.current) { - const leave = (): void => { commitView(viewRef.current); requestAnimationFrame(open); }; - if (settingsLeaveRequesterRef.current) settingsLeaveRequesterRef.current(leave); - else leave(); - } else open(); - }, [commitView, reloadApplicationSettings]); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); const deferredSearchQuery = useDeferredValue(searchQuery); @@ -8821,7 +8805,6 @@ function App(): React.JSX.Element { }; return ( -
- ); } diff --git a/src/renderer/src/ApplicationCenter.test.tsx b/src/renderer/src/ApplicationCenter.test.tsx index e0303440..247c0728 100644 --- a/src/renderer/src/ApplicationCenter.test.tsx +++ b/src/renderer/src/ApplicationCenter.test.tsx @@ -14,7 +14,6 @@ import { import { ApplicationAvailability, ApplicationCenter, - ApplicationSettingsNavigation, ApplicationSettingsView, } from './ApplicationCenter' @@ -34,6 +33,13 @@ const props = () => ({ onUpdate: vi.fn(async () => true), onRetry: vi.fn(), }) +const pinnedSettings = { + ...settings, + applicationNavigation: { + ...defaultApplicationNavigation, + pinned: { 'magic-notes': true, 'local-inference': true }, + }, +} afterEach(cleanup) describe('Application Center', () => { @@ -54,19 +60,25 @@ describe('Application Center', () => { expect(dialog).not.toHaveClass('application-center--detail') }) - it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s fixed with Open and no settings, pin, or order controls', (title, id) => { + it.each([['知识库', 'knowledge'], ['智能心跳', 'heartbeat']])('keeps %s always shown with sorting but no settings or toggles', (title, id) => { const handlers = props() render() const card = screen.getByText(title, { exact: true }).closest('article')! - expect(card).toHaveAttribute('draggable', 'false') + expect(card).toHaveAttribute('draggable', 'true') + expect(within(card).getByText('始终显示')).toBeInTheDocument() expect(within(card).queryByRole('switch')).not.toBeInTheDocument() - expect(within(card).getAllByRole('button')).toHaveLength(1) + expect(within(card).getAllByRole('button')).toHaveLength(3) fireEvent.click(within(card).getByRole('button', { name: '打开' })) expect(handlers.onOpen).toHaveBeenCalledWith(id) const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')! fireEvent.dragStart(notes) fireEvent.drop(card) - expect(handlers.onUpdate).not.toHaveBeenCalled() + expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { + ...defaultApplicationNavigation, + order: id === 'knowledge' + ? ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'] + : ['knowledge', 'magic-notes', 'heartbeat', 'local-inference'], + } }) }) it('searches disabled and unpinned applications, opens without pinning, and restores focus', () => { const handlers = props() @@ -100,7 +112,7 @@ describe('Application Center', () => { expect( screen.getByRole('button', { name: '打开' }), ).toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked() fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(handlers.onClose).toHaveBeenCalledOnce() @@ -109,60 +121,97 @@ describe('Application Center', () => { trigger.remove() }) - it('saves complete order and independent pin preferences without optimistic changes', () => { + it('sorts disabled and unpinned apps and saves pin preferences without optimistic changes', () => { const handlers = props() - render() + render() expect(screen.queryByRole('switch')).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' })) + const inference = screen.getByText('本机推理监控', { exact: true }).closest('article')! + expect(inference).toHaveAttribute('draggable', 'true') + expect(within(inference).getAllByRole('button', { name: /上移|下移/ })).toHaveLength(2) + expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeEnabled() + expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeEnabled() + const notes = screen.getByText('魔法笔记', { exact: true }).closest('article')! + fireEvent.dragStart(inference) + fireEvent.drop(notes) + fireEvent.dragStart(notes) + fireEvent.drop(inference) + expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { + ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + } }) + fireEvent.click(within(inference).getByRole('button', { name: '打开' })) + expect(handlers.onOpen).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) + expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked() + expect(screen.getByRole('switch', { name: '常驻左侧菜单' })).not.toBeChecked() fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' })) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { ...defaultApplicationNavigation, - pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': false }, + pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': true }, }, }) expect( screen.getByRole('switch', { name: '常驻左侧菜单' }), - ).toBeChecked() - fireEvent.click(screen.getByRole('button', { name: '上移 本机推理' })) + ).not.toBeChecked() + expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument() + }) + + it('orders all apps on cards, disables full-order boundaries, and waits for confirmed settings', async () => { + const handlers = props() + const { rerender } = render() + expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' }) + expect(moveUp.closest('article')).not.toBeNull() + expect(moveUp).toHaveAttribute('title', '上移 本机推理监控') + fireEvent.click(moveUp) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { - ...defaultApplicationNavigation, - order: ['local-inference', 'magic-notes'], + ...pinnedSettings.applicationNavigation, + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], }, }) - expect(screen.getByRole('button', { name: '下移 本机推理' })).toBeDisabled() - fireEvent.click(screen.getByRole('button', { name: '返回应用中心' })) - fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' })) - expect(screen.getByRole('button', { name: '上移 魔法笔记' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + expect(await screen.findByRole('status')).not.toBeEmptyDOMElement() + rerender() + expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' })) + expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation }) + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) + expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument() }) it('uses the same order patch for drag and keyboard controls', () => { const handlers = props() - render() + render() const notes = screen .getByText('魔法笔记', { exact: true }) .closest('article')! const inference = screen - .getByText('本机推理', { exact: true }) + .getByText('本机推理监控', { exact: true }) .closest('article')! fireEvent.dragStart(inference) fireEvent.dragOver(notes) fireEvent.drop(notes) expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { - ...defaultApplicationNavigation, - order: ['local-inference', 'magic-notes'], + ...pinnedSettings.applicationNavigation, + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], }, }) }) it('locks all preference controls while saving or awaiting a confirmed read', () => { const handlers = props() - const { rerender } = render() + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) + rerender() for (const toggle of screen.getAllByRole('switch')) expect(toggle).toBeDisabled() - expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled() + expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument() rerender( , ) @@ -173,6 +222,34 @@ describe('Application Center', () => { expect(handlers.onRetry).toHaveBeenCalledOnce() }) + it.each(['pending', 'locked'] as const)('disables card sorting when %s', (state) => { + const handlers = props() + render() + for (const button of screen.getAllByRole('button', { name: /上移|下移/ })) + expect(button).toBeDisabled() + const cards = screen.getAllByRole('article') + for (const card of cards) expect(card).toHaveAttribute('draggable', 'false') + fireEvent.dragStart(cards[3]!) + fireEvent.drop(cards[2]!) + expect(handlers.onUpdate).not.toHaveBeenCalled() + }) + + it('keeps hidden entries in the full order during search while disabling drag', () => { + const handlers = props() + render() + fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } }) + const card = screen.getByRole('article') + expect(card).toHaveAttribute('draggable', 'false') + expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled() + fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' })) + expect(handlers.onUpdate).toHaveBeenCalledWith({ + applicationNavigation: { + ...pinnedSettings.applicationNavigation, + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'], + }, + }) + }) + it('provides a recoverable search empty state and no market or system tools', () => { render() expect(screen.queryByRole('tab')).not.toBeInTheDocument() @@ -212,24 +289,21 @@ describe('Application Center', () => { }) }) - it('keeps disabled page drafts mounted with a recovery entry', () => { - const open = vi.fn() + it('keeps disabled page drafts mounted without a generic settings shortcut', () => { const page = (enabled: boolean) => ( - - - - - + + + ) - const { rerender, container } = render(page(true)) + const { rerender } = render(page(true)) + expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() const draft = screen.getByRole('textbox', { name: 'draft' }) fireEvent.change(draft, { target: { value: 'keep this draft' } }) rerender(page(false)) expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(draft).toBeInTheDocument() expect(screen.getByText('应用已关闭')).toBeInTheDocument() - fireEvent.click(within(container).getByRole('button', { name: '应用设置' })) - expect(open).toHaveBeenCalledWith('local-inference') + expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() rerender(page(true)) expect(screen.getByRole('textbox')).toHaveValue('keep this draft') }) diff --git a/src/renderer/src/ApplicationCenter.tsx b/src/renderer/src/ApplicationCenter.tsx index 9a120b8a..4bc304e4 100644 --- a/src/renderer/src/ApplicationCenter.tsx +++ b/src/renderer/src/ApplicationCenter.tsx @@ -1,6 +1,4 @@ import { - createContext, - useContext, useLayoutEffect, useMemo, useRef, @@ -63,41 +61,19 @@ export function isApplicationEnabled( ) } -export const ApplicationSettingsNavigation = createContext< - ((id: EditableApplicationId) => void) | undefined ->(undefined) - -export function ApplicationSettingsLink({ - id, -}: { - id: EditableApplicationId -}): React.JSX.Element | null { - const open = useContext(ApplicationSettingsNavigation) - const { t } = useTranslation('app') - return open ? ( - - ) : null -} - export function ApplicationAvailability({ - id, enabled, children, }: { - id: EditableApplicationId enabled: boolean children: ReactNode }): React.JSX.Element { const { t } = useTranslation('app') return (
-
- {!enabled && {t('applications.disabledPage')}} - -
+ {!enabled &&
+ {t('applications.disabledPage')} +
}
) diff --git a/src/renderer/src/ApplicationMenu.test.tsx b/src/renderer/src/ApplicationMenu.test.tsx index cda2b76e..df825c28 100644 --- a/src/renderer/src/ApplicationMenu.test.tsx +++ b/src/renderer/src/ApplicationMenu.test.tsx @@ -22,17 +22,17 @@ function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), o } afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) -it('lists enabled apps regardless of pins and keeps Knowledge and Heartbeat fixed before filtering', () => { +it('filters the shared order by enablement only, including reordered always-shown apps', () => { const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, - applicationNavigation: { order: ['local-inference', 'magic-notes'] as ApplicationSettings['applicationNavigation']['order'], + applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'], pinned: { 'magic-notes': false, 'local-inference': false } } } const onOpen = vi.fn() const { rerender } = render() fireEvent.click(screen.getByText('Launcher')) - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用']) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['本机推理监控', '智能心跳', '魔法笔记', '知识库', '管理应用']) rerender() - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '管理应用']) - fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' })) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['本机推理监控', '智能心跳', '知识库', '管理应用']) + fireEvent.click(screen.getByRole('menuitem', { name: '本机推理监控' })) expect(onOpen).toHaveBeenCalledWith('local-inference') expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) diff --git a/src/renderer/src/ApplicationMenu.tsx b/src/renderer/src/ApplicationMenu.tsx index f6654fc5..805dd841 100644 --- a/src/renderer/src/ApplicationMenu.tsx +++ b/src/renderer/src/ApplicationMenu.tsx @@ -18,7 +18,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, const { t } = useTranslation('app') const applications = useMemo(() => { const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order - return ['knowledge' as const, 'heartbeat' as const, ...order] + return order .filter(id => isApplicationEnabled(settings, id)) }, [settings]) return diff --git a/src/renderer/src/LocalInferencePage.test.tsx b/src/renderer/src/LocalInferencePage.test.tsx index 1af3419e..21b8b7c0 100644 --- a/src/renderer/src/LocalInferencePage.test.tsx +++ b/src/renderer/src/LocalInferencePage.test.tsx @@ -12,12 +12,13 @@ const snapshot: LocalInferenceSnapshot = { services: [{ id: 'embedding', name: '向量生成', engine: 'Granite', ownership: 'managed-process', state: 'running', detail: '共享执行服务', actions: ['stop', 'restart'] }], tasks: [{ id: 'real-task', serviceId: 'embedding', source: '知识索引', state: 'running', startedAt: 1 }] } -const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn() } +const api = { getSnapshot: vi.fn(), act: vi.fn(), cancel: vi.fn(), openSettings: vi.fn() } beforeEach(() => { vi.resetAllMocks() api.getSnapshot.mockResolvedValue(snapshot) api.act.mockResolvedValue(undefined) api.cancel.mockResolvedValue(undefined) + api.openSettings.mockResolvedValue(undefined) vi.stubGlobal('goodbuddy', { localInference: api }) }) afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) @@ -51,6 +52,9 @@ describe('LocalInferencePage', () => { const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement! expect(toolbar).toHaveClass('local-inference-toolbar') expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument() + fireEvent.click(within(toolbar).getByRole('button', { name: '模型与连接设置' })) + expect(api.openSettings).toHaveBeenCalledOnce() expect(styles).toMatch(/\.local-inference-toolbar\s*\{[^}]*flex: 0 0 auto/) expect(screen.getByRole('region', { name: '本机推理服务' })).toBeInTheDocument() }) @@ -132,12 +136,12 @@ describe('LocalInferencePage', () => { it('isolates nested Escape, traps focus, and restores the service action', async () => { const onClose = vi.fn() render() - expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus() + expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus() await screen.findByRole('heading', { name: '向量生成' }) - fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理' }), { key: 'Tab', shiftKey: true }) + fireEvent.keyDown(screen.getByRole('button', { name: '关闭本机推理监控' }), { key: 'Tab', shiftKey: true }) expect(screen.getByRole('button', { name: '重启服务' })).toHaveFocus() fireEvent.keyDown(screen.getByRole('button', { name: '重启服务' }), { key: 'Tab' }) - expect(screen.getByRole('button', { name: '关闭本机推理' })).toHaveFocus() + expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toHaveFocus() const stop = screen.getByRole('button', { name: '停止服务' }) stop.focus() fireEvent.click(stop) @@ -169,12 +173,12 @@ describe('LocalInferencePage', () => { fireEvent.click(confirm) fireEvent.keyDown(confirm, { key: 'Escape' }) expect(screen.getByRole('button', { name: '保留运行状态' })).toBeDisabled() - expect(screen.getByRole('button', { name: '关闭本机推理' })).toBeDisabled() + expect(screen.getByRole('button', { name: '关闭本机推理监控' })).toBeDisabled() expect(onClose).not.toHaveBeenCalled() expect(api.act).toHaveBeenCalledOnce() resolve() await waitFor(() => expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument()) - expect(screen.getByRole('button', { name: '关闭本机推理' })).not.toBeDisabled() + expect(screen.getByRole('button', { name: '关闭本机推理监控' })).not.toBeDisabled() }) it('restores fallback focus when the entry was removed and clears background isolation', () => { @@ -213,12 +217,12 @@ describe('LocalInferencePage', () => { expect(release).toHaveBeenCalledOnce() expect(api.act).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled() - expect(screen.getByRole('dialog', { name: '本机推理' })).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: '本机推理监控' })).toBeInTheDocument() }) it('keeps keyboard focus in the modal when the application content is disabled', () => { render() - const close = screen.getByRole('button', { name: '关闭本机推理' }) + const close = screen.getByRole('button', { name: '关闭本机推理监控' }) fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) expect(close).toHaveFocus() fireEvent.keyDown(close, { key: 'Tab' }) diff --git a/src/renderer/src/LocalInferencePage.tsx b/src/renderer/src/LocalInferencePage.tsx index aeefccd7..93c706b9 100644 --- a/src/renderer/src/LocalInferencePage.tsx +++ b/src/renderer/src/LocalInferencePage.tsx @@ -5,7 +5,6 @@ import type { InferenceAction, InferenceService, InferenceTask, LocalInferenceSn import { PageHeader } from './WorkspacePrimitives' import { activateModalFocus, trapTabFocus } from './dialog-focus' import { getOcrInferenceState, loadOcrInference, releaseOcrInference } from './document-ocr-bridge' -import { ApplicationSettingsLink } from './ApplicationCenter' import './local-inference.css' const stateLabels: Record = { @@ -112,7 +111,7 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc else setControl(selected) } - return createPortal(
{ + return createPortal(
{ if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return trapTabFocus(event, event.currentTarget) if (event.key === 'Escape') { @@ -122,11 +121,11 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc } }}>
- { if (!operation.current && !control) onClose() }}>
- {!enabled &&
应用已关闭
} + {!enabled &&
应用已关闭
}