feat: unify app ordering and simplify app settings access
This commit is contained in:
+8
-6
@@ -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
@@ -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
@@ -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 内循环;标题与关闭按钮保持可见,正文独立滚动
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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: '内置',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user