feat: unify app ordering and simplify app settings access

This commit is contained in:
mesalogo
2026-09-18 15:29:00 +08:00
parent bbae893c45
commit ca3c919a34
26 changed files with 543 additions and 332 deletions
+8 -6
View File
@@ -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.
+1 -1
View File
@@ -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)。
+5 -5
View File
@@ -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、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
@@ -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 承载 |
| 插件应用 | 未来由插件提供的一级页面入口 |
| 插件工具 | 未来由插件提供的辅助面板或命令入口 |
@@ -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 评论方式和评论形式继续为单选,沿用现有默认值“即时”和“长评 + 要点”,不能解释为
全部模式同时启用。迁移保留既有单选值及业务数据。关闭数量显示只隐藏徽标,不修改待办。
@@ -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 ? `<button data-app-settings="${app.id}">${icon('settings')} 应用设置</button>` : '';
$('#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 = `<h1>本机推理管理</h1><p class="muted">统一查看语音、OCR、向量等本机推理任务与执行服务。任务进度、引擎及占用均为模拟数据。</p><h2>推理任务与服务</h2><p class="muted">下方操作作用于执行服务;启动或加载服务不会自动恢复已中断的任务。</p><div class="model-list">${modelServices.map(service => {
$('#page-content').innerHTML = `<h1>本机推理监控</h1><p class="muted">统一查看语音、OCR、向量等本机推理任务与执行服务。任务进度、引擎及占用均为模拟数据。</p><h2>推理任务与服务</h2><p class="muted">下方操作作用于执行服务;启动或加载服务不会自动恢复已中断的任务。</p><div class="model-list">${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 = [];
@@ -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 桌面进程统计范围不因此扩大。
@@ -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 继续保持单一列表。
@@ -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<EditableApplicationId, boolean>
}
```
`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. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。
@@ -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` 时移到正文下方并左对齐,保留完整状态和原因。
@@ -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 内循环;标题与关闭按钮保持可见,正文独立滚动
+63 -4
View File
@@ -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<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
const legacy: Record<string, unknown> = { ...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<string, unknown> = { ...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([])
})
+16
View File
@@ -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()
})
+77 -23
View File
@@ -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(<App />)
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(<App />)
@@ -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<typeof updates.onSettingsChanged>[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(<App />)
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(<App />)
@@ -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(<App />)
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<NonNullable<typeof api.updates>['onSettingsChanged']>[0]
vi.mocked(api.updates!.onSettingsChanged).mockImplementation(listener => {
changed = listener
return vi.fn()
})
render(<App />)
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())
+2 -22
View File
@@ -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<HTMLButtonElement>(null);
const [applicationSettingsTarget, setApplicationSettingsTarget] = useState<EditableApplicationId>();
const [applicationSettings, setApplicationSettings] = useState<ApplicationSettings>();
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<HTMLInputElement>(null);
const deferredSearchQuery = useDeferredValue(searchQuery);
@@ -8821,7 +8805,6 @@ function App(): React.JSX.Element {
};
return (
<ApplicationSettingsNavigation value={openApplicationSettings}>
<div className="app-shell">
<aside
aria-label={
@@ -10948,7 +10931,7 @@ function App(): React.JSX.Element {
active={view === "magic-notes"}
route="magic-notes"
>
<ApplicationAvailability id="magic-notes" enabled={magicNotesEnabled}>
<ApplicationAvailability enabled={magicNotesEnabled}>
<PageShell variant="master-detail">
<RouteErrorBoundary
key="magic-notes"
@@ -11375,7 +11358,6 @@ function App(): React.JSX.Element {
pending={applicationSettingsPending}
locked={applicationSettingsUnconfirmed}
error={applicationSettingsError}
initialApplication={applicationSettingsTarget}
onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return;
@@ -11399,7 +11381,6 @@ function App(): React.JSX.Element {
if (window.innerWidth < 900) setSidebarOpen(false);
}}
onManage={() => {
setApplicationSettingsTarget(undefined);
setApplicationCenterOpen(true);
}}
onRetry={() => void reloadApplicationSettings()}
@@ -11716,7 +11697,6 @@ function App(): React.JSX.Element {
</div>
</div>
</div>
</ApplicationSettingsNavigation>
);
}
+108 -34
View File
@@ -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(<ApplicationCenter {...handlers} />)
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(<ApplicationCenter {...handlers} />)
render(<ApplicationCenter {...handlers} settings={{ ...settings, localInferenceEnabled: false }} />)
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(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
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(<ApplicationCenter {...handlers} settings={{
...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
}} />)
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(<ApplicationCenter {...handlers} />)
render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
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(<ApplicationCenter {...handlers} pending initialApplication="local-inference" />)
const { rerender } = render(<ApplicationCenter {...handlers} />)
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
rerender(<ApplicationCenter {...handlers} pending />)
for (const toggle of screen.getAllByRole('switch'))
expect(toggle).toBeDisabled()
expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled()
expect(screen.queryByRole('button', { name: /上移|下移/ })).not.toBeInTheDocument()
rerender(
<ApplicationCenter {...handlers} locked error="无法确认保存结果" />,
)
@@ -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(<ApplicationCenter {...handlers} settings={pinnedSettings} {...{ [state]: true }} />)
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(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
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(<ApplicationCenter {...props()} />)
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) => (
<ApplicationSettingsNavigation value={open}>
<ApplicationAvailability id="local-inference" enabled={enabled}>
<input aria-label="draft" defaultValue="original" />
</ApplicationAvailability>
</ApplicationSettingsNavigation>
<ApplicationAvailability enabled={enabled}>
<input aria-label="draft" defaultValue="original" />
</ApplicationAvailability>
)
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')
})
+44 -71
View File
@@ -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 ? (
<button className="secondary-button" type="button" onClick={() => open(id)}>
<Settings size={15} aria-hidden="true" />
{t('applications.settings')}
</button>
) : null
}
export function ApplicationAvailability({
id,
enabled,
children,
}: {
id: EditableApplicationId
enabled: boolean
children: ReactNode
}): React.JSX.Element {
const { t } = useTranslation('app')
return (
<div className="application-page">
<div className="application-page-controls">
{!enabled && <strong>{t('applications.disabledPage')}</strong>}
<ApplicationSettingsLink id={id} />
</div>
{!enabled && <div className="application-page-controls">
<strong>{t('applications.disabledPage')}</strong>
</div>}
<div
className="application-page-content"
hidden={!enabled}
@@ -267,7 +243,6 @@ export function ApplicationCenter({
pending,
locked = false,
error,
initialApplication,
onClose,
onOpen,
onUpdate,
@@ -277,7 +252,6 @@ export function ApplicationCenter({
pending: boolean
locked?: boolean
error?: string
initialApplication?: EditableApplicationId
onClose: () => void
onOpen: (id: BuiltInApplicationId) => void
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
@@ -285,8 +259,8 @@ export function ApplicationCenter({
}): React.JSX.Element {
const { t } = useTranslation('app')
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(initialApplication)
const [dragged, setDragged] = useState<EditableApplicationId>()
const [selected, setSelected] = useState<EditableApplicationId>()
const [dragged, setDragged] = useState<BuiltInApplicationId>()
const [announcement, setAnnouncement] = useState('')
const dialog = useRef<HTMLElement>(null)
const body = useRef<HTMLDivElement>(null)
@@ -305,7 +279,7 @@ export function ApplicationCenter({
const editingDisabled = pending || locked
const results = useMemo(
() =>
['knowledge' as const, 'heartbeat' as const, ...navigation.order].filter((id) =>
navigation.order.filter((id) =>
`${t(applicationDefinitions[id].title)} ${t(`applications.descriptions.${id}`)}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
@@ -313,8 +287,8 @@ export function ApplicationCenter({
[navigation.order, query, t],
)
const move = async (
from: EditableApplicationId,
to: EditableApplicationId,
from: BuiltInApplicationId,
to: BuiltInApplicationId,
): Promise<void> => {
if (editingDisabled || from === to) return
const order = [...navigation.order]
@@ -396,35 +370,12 @@ export function ApplicationCenter({
{!settings ? (
<p role="status">{t('route.loading')}</p>
) : selected ? (
<>
<ApplicationSettingsView
id={selected}
settings={settings}
pending={editingDisabled}
onUpdate={onUpdate}
/>
<div className="application-center__order">
{([-1, 1] as const).map((offset) => {
const target =
navigation.order[navigation.order.indexOf(selected) + offset]
const Icon = offset === -1 ? ArrowUp : ArrowDown
return (
<button
key={offset}
className="secondary-button"
type="button"
disabled={editingDisabled || !target}
onClick={() => { if (target) void move(selected, target) }}
>
<Icon size={16} aria-hidden="true" />
{t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
name: t(applicationDefinitions[selected].title),
})}
</button>
)
})}
</div>
</>
<ApplicationSettingsView
id={selected}
settings={settings}
pending={editingDisabled}
onUpdate={onUpdate}
/>
) : (
<>
<label className="application-center__search">
@@ -452,20 +403,20 @@ export function ApplicationCenter({
const definition = applicationDefinitions[id]
const Icon = definition.icon
const enabled = isApplicationEnabled(settings, id)
const fixed = id === 'knowledge' || id === 'heartbeat'
const alwaysShown = id === 'knowledge' || id === 'heartbeat'
return (
<article
className="application-center__item"
key={id}
draggable={!fixed && !editingDisabled && !query}
onDragStart={() => { if (!fixed) setDragged(id) }}
draggable={!editingDisabled && !query}
onDragStart={() => { if (!editingDisabled && !query) setDragged(id) }}
onDragEnd={() => setDragged(undefined)}
onDragOver={(event) => {
if (!fixed && dragged && !editingDisabled) event.preventDefault()
if (dragged && !editingDisabled && !query) event.preventDefault()
}}
onDrop={(event) => {
event.preventDefault()
if (!fixed && dragged) void move(dragged, id)
if (dragged && !query) void move(dragged, id)
setDragged(undefined)
}}
>
@@ -475,8 +426,8 @@ export function ApplicationCenter({
<div className="application-center__title">
<strong>{t(definition.title)}</strong>
<small>
{fixed
? t('applications.fixed')
{alwaysShown
? t('applications.alwaysShown')
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
</small>
</div>
@@ -492,7 +443,7 @@ export function ApplicationCenter({
>
{t('applications.open')}
</button>
{!fixed && (
{!alwaysShown && (
<button
className="secondary-button"
type="button"
@@ -504,6 +455,28 @@ export function ApplicationCenter({
{t('applications.settings')}
</button>
)}
<div className="application-center__order">
{([-1, 1] as const).map((offset) => {
const target = navigation.order[navigation.order.indexOf(id) + offset]
const MoveIcon = offset === -1 ? ArrowUp : ArrowDown
const label = t(offset === -1 ? 'applications.moveUp' : 'applications.moveDown', {
name: t(definition.title),
})
return (
<button
key={offset}
className="icon-button"
type="button"
aria-label={label}
title={label}
disabled={editingDisabled || !target}
onClick={() => { if (target) void move(id, target) }}
>
<MoveIcon size={16} aria-hidden="true" />
</button>
)
})}
</div>
</div>
</article>
)
+5 -5
View File
@@ -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(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用'])
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['本机推理监控', '智能心跳', '魔法笔记', '知识库', '管理应用'])
rerender(<Harness value={{ ...value, magicNotesEnabled: false }} onOpen={onOpen} />)
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()
})
+1 -1
View File
@@ -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 <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
+12 -8
View File
@@ -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(<LocalInferencePage onClose={onClose} />)
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(<LocalInferencePage onClose={vi.fn()} enabled={false} />)
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' })
+4 -5
View File
@@ -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<string, string> = {
@@ -112,7 +111,7 @@ export default function LocalInferencePage({ onClose, enabled = true, restoreFoc
else setControl(selected)
}
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理" tabIndex={-1} onKeyDown={(event) => {
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-modal" role="dialog" aria-modal="true" aria-label="本机推理监控" tabIndex={-1} onKeyDown={(event) => {
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
}
}}>
<div className="custom-task-dialog__header">
<PageHeader headingId="local-inference-title" title="本机推理" scope={{ kind: 'global' }}
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理" title="关闭本机推理" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
<PageHeader headingId="local-inference-title" title="本机推理监控" scope={{ kind: 'global' }}
actions={<button ref={close} type="button" className="icon-button" aria-label="关闭本机推理监控" title="关闭本机推理监控" disabled={busy || Boolean(control)} onClick={() => { if (!operation.current && !control) onClose() }}><X size={20} aria-hidden="true" /></button>} />
</div>
<div className="local-inference-modal__body">
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong><ApplicationSettingsLink id="local-inference" /></div>}
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong></div>}
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
<div className="local-inference-toolbar">
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
+4 -9
View File
@@ -66,7 +66,7 @@
}
.application-center__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--space-4);
}
.application-center__summary {
@@ -108,11 +108,9 @@
}
.application-center__order {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
border-top: 1px solid var(--border-subtle);
margin-top: var(--space-4);
padding-top: var(--space-4);
flex: 0 0 auto;
gap: var(--space-1);
margin-left: auto;
}
.application-settings {
display: grid;
@@ -156,9 +154,6 @@
.application-center__body {
padding: var(--space-4);
}
.application-center__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 540px) {
.application-settings .platform-feature-option {
+3 -3
View File
@@ -3,10 +3,10 @@ import type { app as chineseApp } from '../zh-CN/app'
export const app = {
applications: {
fixed: 'Fixed entry', optional: 'Optional',
alwaysShown: 'Always shown', optional: 'Optional',
manage: 'Manage Apps',
title: 'Application Center', description: 'Open built-in applications and manage optional apps, pins, and order. Knowledge and Smart Heartbeat have fixed sidebar entries.',
localInference: 'Local Inference', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
title: 'Application Center', description: 'Open and reorder built-in applications, and manage optional apps and pins. Knowledge and Smart Heartbeat are always shown and can be moved.',
localInference: 'Local Inference Monitor', settings: 'Application settings', enable: 'Enable application', pin: 'Pin to sidebar',
enableHelp: 'Pinning only controls the sidebar shortcut. Disabling preserves data and pins without stopping existing requests or background services.',
disabledPage: 'Application is disabled', close: 'Close application center', retry: 'Reload settings', back: 'Back to application center',
search: 'Search built-in applications', noResults: 'No matching applications', clear: 'Clear search', builtin: 'Built-in',
+3 -3
View File
@@ -1,9 +1,9 @@
export const app = {
applications: {
fixed: '固定入口', optional: '可选',
alwaysShown: '始终显示', optional: '可选',
manage: '管理应用',
title: '应用中心', description: '打开内置应用,管理可选应用的启用、常驻和顺序。知识库和智能心跳固定显示在左侧菜单。',
localInference: '本机推理', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
title: '应用中心', description: '打开和排列内置应用,管理可选应用的启用与常驻。知识库和智能心跳始终显示,位置可调整。',
localInference: '本机推理监控', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
+4 -4
View File
@@ -35,12 +35,12 @@ export type BuiltInApplicationId = typeof builtInApplicationIds[number]
export const editableApplicationIds = ['magic-notes', 'local-inference'] as const
export type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = {
order: [...editableApplicationIds],
pinned: { 'magic-notes': true, 'local-inference': true }
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, 'local-inference': false }
}
export const applicationNavigationSchema = z.object({
order: z.array(z.enum(editableApplicationIds)).length(editableApplicationIds.length)
.refine(ids => new Set(ids).size === editableApplicationIds.length, 'Each application must occur exactly once'),
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
.refine(ids => new Set(ids).size === builtInApplicationIds.length, 'Each application must occur exactly once'),
pinned: z.object({
'magic-notes': z.boolean(), 'local-inference': z.boolean()
}).strict()
@@ -18,6 +18,22 @@ import { defaultLocalToolEnvironmentSettings } from './local-tool-environment-co
import { defaultApplicationNavigation } from './application-settings-contracts'
describe('GoodBuddy configuration contracts', () => {
it('uses the authoritative full application order for configuration-tool writes', () => {
const operation = {
operation: 'application.update',
updates: { applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
} },
}
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order },
} }).success).toBe(false)
}
})
it('publishes one valid generated example for every operation', () => {
const operationNames = goodbuddyConfigOperationNameSchema.options