feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -31,7 +31,7 @@ risk or an explicit user requirement.
|
||||
## Design Simplicity
|
||||
|
||||
Follow KISS and YAGNI. Use the smallest conventional design that completes the
|
||||
real user workflow.
|
||||
real user workflow. NO over-engineering or over-design.
|
||||
|
||||
- A demonstrated failure authorizes only the smallest proportional fix, not a
|
||||
general recovery, compatibility, or security framework.
|
||||
|
||||
+27
-4
@@ -111,10 +111,33 @@ records are listed separately and do not introduce another feature status.
|
||||
- [ ] **Project Agent Space** (planned): Unifies roles, knowledge, Skills/MCP,
|
||||
models, approval policy, budgets, and timeouts in a Project, with reusable
|
||||
templates.
|
||||
- [ ] **Application navigation and system-tool shortcuts** (design only):
|
||||
Plans fixed system entries, configurable built-in application visibility,
|
||||
and footer shortcuts into the existing workbar. These are not implemented
|
||||
interface changes. See the [design](./docs/features/application-tool-navigation/README.md).
|
||||
- [ ] **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
|
||||
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
|
||||
`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.
|
||||
The `local-inference` modal shows a single service list without task history or external
|
||||
connections. Embedding shows its independent process CPU and working-set memory;
|
||||
ASR/OCR explain shared-process attribution limits. ASR readiness is unknown rather
|
||||
than inferred from active requests. Supported service controls retain impact confirmation;
|
||||
failed operations refresh the list and require a new confirmation. TTS remains unavailable.
|
||||
Cancelling inference remains active in service-stop impact until worker
|
||||
acknowledgement or exit. Previous validation: typecheck and `npx eslint src` passed; full `npm test` finished with
|
||||
4,448 passed, 67 skipped, and two unchanged Heartbeat migration failures (`duplicate column
|
||||
name: pinned`). Full lint still reports 22 unrelated story-graph demo `document` errors.
|
||||
Eight Electron fixture theme/size scenarios cover keyboard, focus, geometry, native-view
|
||||
occlusion, and actual settings-store persistence, not full production App acceptance.
|
||||
Latest validation, prior real local-engine evidence, and remaining limits are preserved in
|
||||
[validation progress](./docs/features/application-tool-navigation/progress.md).
|
||||
- [ ] **Additional assistant workbar and execution-space capabilities**
|
||||
(planned): Builds on the current workbar and multiple terminals with
|
||||
supervision, unified Runtime monitoring, managed processes, safe static HTML
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
|
||||
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [ ] **应用导航与系统工具快捷入口**(仅设计):规划固定系统入口、内置应用显隐和打开现有工作栏的底部工具按钮;尚未实现新界面。详见[设计文档](./docs/features/application-tool-navigation/README.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)。
|
||||
|
||||
|
||||
+16
-10
@@ -315,10 +315,11 @@
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 侧栏底部提供应用中心入口,设置按钮独立靠右。中心与共同应用设置使用和全局设置相同的整窗 Modal;魔法笔记、智能心跳、本机推理可启用、常驻与排序,
|
||||
取消常驻不关闭功能。知识库固定显示在首个可选应用槽位之后,再过滤隐藏的可选项;不参与启用、常驻或排序,中心只提供“打开”,原工作区实际配置保留,没有实际设置时不添加通用设置按钮。
|
||||
- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。
|
||||
知识库、智能心跳始终启用,依次固定在可选应用之前;不参与启用、常驻或排序,管理 Modal 仅提供“打开”,不增设通用空设置。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理可启用、常驻与排序,取消常驻不关闭功能。
|
||||
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
|
||||
具体规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
管理 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 助手工作栏
|
||||
|
||||
@@ -685,14 +686,19 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- 保留现有助手工作栏入口,首期在窄栏内适度完善,不先扩张成新的独立一级页面。
|
||||
- 只展示 Task;普通 Conversation、Job、Run、工具步骤、Subagent 和心跳事项不独立占行。
|
||||
- 每项显示名称、关联 Conversation、Global 或 Project 范围、Ask/Execute、状态、最近进展、
|
||||
最近真实活动时间及需要关注信息。
|
||||
- 范围固定为当前项目,不提供全局或所有项目入口;切换会话不筛掉本项目其他会话的任务。
|
||||
- 顶部紧凑显示当前会话消息总数和 Agent 累计回复时间,按每轮请求区间累加,包含轮内工具和
|
||||
审批等待,不计轮间空闲。历史缺失显示不完整提示,远程缺少可靠证据时不虚构用时。
|
||||
- 每项显示名称、关联 Conversation、所属项目、Ask/Execute、状态、最近进展、
|
||||
最近真实活动时间、累计执行时间及需要关注信息。任务用时汇总历次执行请求,不按父任务生命周期计算。
|
||||
- 点击列表项打开关联 Conversation 并定位该 Task,不显示第二份内容载体。
|
||||
- 进行中、暂停和已结束使用共享 `SegmentedControl`;窄栏不足时单行滚动。状态归类遵循[任务中心 PRD](docs/features/task-and-job/task-center-prd.md#5-交互)。
|
||||
- 项目任务标题右侧依次为“+”新建和筛选图标;图标按钮提供可访问名称,筛选按钮提供展开状态。
|
||||
状态筛选默认收起且为全部;展开后用共享 `SegmentedControl` 提供全部、进行中、暂停、已结束,
|
||||
窄栏不足时单行滚动。收起保留条件,非全部时图标高亮并显示条件。状态归类遵循[任务中心 PRD](docs/features/task-and-job/task-center-prd.md#5-交互)。
|
||||
- 完整消息留在 Conversation;长错误、工具、审批和成果按 Task 关联到 Runtime、活动记录和
|
||||
成果查看器,不撑高列表,也不显示 Job/Run 树。
|
||||
- “新建定制任务”使用共享 Modal,明确选择当前或新 Conversation。聊天入口默认当前
|
||||
Conversation,Task Center 入口默认新 Conversation;选择必须持续可见。
|
||||
- “新建定制任务”使用共享 Modal,默认立即执行和当前 Conversation,可选择本项目其他可用
|
||||
Conversation 或新建会话,选择必须持续可见。内容必填,名称可选并从内容生成;定时提供单次、每日和每周。
|
||||
- 定时消息创建 Modal 使用关联 Conversation 的 Runtime 与 Ask/Execute 模式,不再提供独立
|
||||
模式开关。未运行计划的详情显示会话当前模式,已运行任务显示该次执行记录的模式;
|
||||
不得用旧计划字段表示下一次执行权限。不支持工具时不得静默降级。
|
||||
@@ -745,14 +751,14 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目(技术预览)”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 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、离线、无工具能力或调用错误不改变勾选。图片模型默认关闭会话调用;默认生图模型独立于普通默认连接,关闭或删除后保留失效选择并提示改选,不自动回退。
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 本机专用推理架构与任务控制评估
|
||||
|
||||
## 记录范围与状态
|
||||
|
||||
评估日期:2026-09-18。本文记录 ASR、OCR、内置 embedding 的执行边界、任务控制缺口和后续优化依据,供语音输入、文档解析、知识检索与索引共同使用。
|
||||
|
||||
现状依据本次工作区源码核对,包含尚未提交的变更,不等同于已发布版本或实机验收结论。本次仅整理文档,未运行引擎性能测试、复现故障或实施迁移。下文“建议”是评估方向,“待办”均未在本次实施,也没有约定交付日期。
|
||||
|
||||
产品 Task、Job、Run 的定义沿用 [Task 与 Job 领域模型](../features/task-and-job/task-and-job-model.md)。本文的“推理任务”指一次引擎请求及其执行记录,不自动等同于产品 Task,不据此新增产品对象、持久化日志或全局调度器。本机推理管理的现有界面和接入清单见[应用导航技术设计](../features/application-tool-navigation/technical-design.md#111-本机推理任务与执行服务);本文负责跨功能架构评估及优化待办。
|
||||
|
||||
## 当前执行边界
|
||||
|
||||
| 能力 | 源码现状 | 所有权与释放 | 评估建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| ASR | 录音结果就绪后提交完整 PCM;Main 创建 `node:worker_threads` Worker,以 sherpa-onnx WASM 执行离线识别;服务一次只接受一个请求 | 每请求创建识别器;超时或 abort 调用并等待 `worker.terminate()`;正常结果路径的清理确认存在缺口,见 TC-4 | 保留请求级 Worker,适合间歇语音输入;常驻化需流式需求或测量支持 |
|
||||
| OCR | Main 排队后发往主窗口 Renderer;Renderer 复用 Web Worker,运行 `ppu-paddle-ocr/web` 与 `onnxruntime-web` WASM;文档串行处理,PDF 逐页处理 | Renderer 模块持有 Worker;活动请求取消或单页超时会终止 Worker;无活动和排队请求时空闲 60 秒释放 | 当前保留 Web Worker;独立于 UI 的持续任务可评估专用后台 Renderer 加 Worker |
|
||||
| 内置 embedding | Main 按模型目录共享引用计数 Broker;transport 使用 `utilityProcess.fork`,Granite tokenizer 和 ONNX session 首次请求懒加载并复用 | 多使用方共享进程;最后一个 provider 释放时 shutdown;也支持显式启停,停止等待 child exit | 保留共享 utilityProcess;优先核对取消后的容量回收与加载失败重试 |
|
||||
|
||||
### ASR 的证据与边界
|
||||
|
||||
[App.tsx](../../src/renderer/src/App.tsx) 在 `recording.result` 完成后调用 `speech.transcribe`;[录音模块](../../src/renderer/src/speech-recognition.ts)负责采样与重采样。[SpeechTranscriptionService](../../src/main/speech/speech-transcription-service.ts) 的 `active.size` 拒绝并发识别,`runSherpaTranscription` 为每次请求创建 Worker,转移音频缓冲区,使用 `createOfflineRecognizer` 同步 decode。Worker 创建后的识别超时为 120 秒,不包含此前模型解析的等待时间。
|
||||
|
||||
[package.json](../../package.json) 固定 `sherpa-onnx` 为 1.13.4;本次同时核对已安装包的 `index.js`,其入口加载 `sherpa-onnx-wasm-nodejs.js`。因此这里记录的是 Node Worker 内的 WASM 路径。Node Worker 与 Main 共享 OS 进程,线程隔离不能视为独立进程故障边界。
|
||||
|
||||
Worker 先 `postMessage`,再在 `finally` 中调用 `stream.free()` 和 `recognizer.free()`。Main 收到结果后以 `finish(..., false)` 结算、移除监听和超时,不等待 Worker exit;随后 `active` 删除请求,推理记录转为终态。这证明结果完成与资源清理确认之间存在记账间隙,但尚未证明实际泄漏或清理耗时。
|
||||
|
||||
### OCR 的浏览器依赖
|
||||
|
||||
[OCR Worker](../../src/renderer/src/document-ocr-worker.ts) 显式选择 WASM provider、`canvas-native` 图像处理,当前 `ort.env.wasm.numThreads = 1`。PDF 路径使用 `pdfjs-dist`、`OffscreenCanvas` 和 `convertToBlob`。本次核对的依赖链为 `ppu-paddle-ocr/web/platform.web.js` 到 `ppu-ocv/canvas-web`;后者的 `platform/web.js` 使用 `createImageBitmap` 解码图片。包依赖以 [package-lock.json](../../package-lock.json) 为准,依赖升级后需重新核对此结论。
|
||||
|
||||
这些能力与浏览器执行环境有关。直接迁到 Node utilityProcess,需要另行验证图像解码、Canvas、PDF 渲染及打包资源路径,不能只替换进程创建 API 就宣称等价。
|
||||
|
||||
[DocumentOcrBroker](../../src/main/document-ocr-broker.ts) 最多保留 4 个 pending 请求,一次派发一个;总超时从派发时开始,不覆盖 Main 排队等待。其预算为 `min(10 分钟, max(单页预算, 60 秒 + 单页预算 × 页数))`。[Renderer bridge](../../src/renderer/src/document-ocr-bridge.ts) 另有串行 Promise 队列,Worker 就绪超时为 120 秒,就绪后按单页预算计时,并在进度消息到达时重置单页计时器。Main 的 60 秒启动预算与 Renderer 的 120 秒就绪等待未对齐,短文档冷启动时 Main 有机会先判超时。
|
||||
|
||||
[Main 初始化](../../src/main/index.ts) 将 Broker 绑定到 `mainWindow`;[Renderer 入口](../../src/renderer/src/main.tsx) 安装 bridge。Broker 派发前仅检查窗口销毁等条件,没有等待 bridge 就绪的握手,也没有在该派发链路中处理 bridge 就绪丢失。Renderer 内的 Worker `ready` 只确认模型初始化,不能证明 Main 发请求时 Renderer 已安装 IPC 监听。窗口重载、Renderer 崩溃及启动时监听尚未安装的影响应按 TC-3 复现。
|
||||
|
||||
来源请求可以取消 OCR;已派发请求取消时 Main 发送取消消息并立即结算,然后派发下一项,未等待 Renderer 清理确认。Renderer 对当前活动请求的取消会终止共享 Worker。管理页因此没有将该操作作为安全的单任务取消能力;手动释放也会拒绝活动或排队请求。
|
||||
|
||||
### Embedding 的共享执行与取消
|
||||
|
||||
[Main provider 工厂](../../src/main/index.ts) 按模型目录维护 `sharedEmbeddingBrokers` 及使用计数;[transport](../../src/main/knowledge/embedding-utility-transport.ts) 以 spawn 确认进程启动。[Granite 引擎](../../src/main/knowledge/granite-embedding-engine.ts) 缓存 `loadTask`,在首次请求时创建 tokenizer 和 WASM ONNX session。进程已启动不等于模型已加载;当前没有为模型加载完成提供独立服务状态。
|
||||
|
||||
[Broker](../../src/main/knowledge/embedding-inference-broker.ts) 默认请求超时为 120 秒,计时在 transport 就绪后建立 pending 时开始。调用方取消或超时会先返回错误,同时发送 cancel,保留 pending 和推理执行记录,等待结果、`CANCELLED` 或进程退出。保留容量避免把仍在计算的任务当作已释放。
|
||||
|
||||
[执行端](../../src/main/knowledge/embedding-inference-worker.ts) 收到 cancel 后设置 AbortSignal;Granite 在加载、输入构造和 `session.run` 前后检查取消,但没有将可抢占的取消句柄传入 `session.run`。若该调用一直不返回,取消检查和确认就无法完成。Broker 的 `cancelPending` 会清除原计时器,当前没有针对该 pending 的后续有界清理升级,因此持续挂起可一直占用在途容量。此为源码可达的条件风险,真实引擎挂起尚未复现。
|
||||
|
||||
`loadTask ??= this.loadFiles(...)` 也会缓存被拒绝的加载 Promise;同一引擎实例后续请求复用同一个失败,不能通过再次请求重新加载。重建进程会建立新引擎;是否增加实例内重试及其清理方式,按 TC-2 验证后确定。
|
||||
|
||||
## 架构建议与触发条件
|
||||
|
||||
ASR 继续按录音请求创建 Worker。对低频、间歇输入,每次释放模型能避免长期占用;本次没有冷启动与常驻成本数据,不能量化收益。只有连续流式识别成为真实需求,或高频请求测出重复加载已影响目标延迟,才评估常驻 utilityProcess。届时需比较加载成本、暖请求延迟、空闲工作集和取消时延,并重新设计音频流与识别器复用;单纯常驻不会把当前离线 API 变成流式识别。
|
||||
|
||||
OCR 保留 Web Worker。如果文档任务需要在主 UI 重载或不可用时继续执行,优先评估由 Main 持有的专用后台 Renderer 加 Worker,以保留浏览器图像/PDF 能力。该方案仍是候选,需要完成以下设计与实机验证后才可决定迁移:
|
||||
|
||||
- 保持现有 sandbox、context isolation、禁用 Node integration 的边界;通过专用 preload 暴露必要方法,并沿用可信 sender 与输入校验。当前 IPC 若限定主窗口 sender,需要明确新增宿主的受信身份及响应归属。
|
||||
- 分别确认后台 Renderer 的 IPC 监听就绪与 Worker 模型就绪;定义重载、崩溃、启动失败及应用退出时 pending、队列和模型的处置,防止静默丢请求或自动重放。
|
||||
- Main 持有后台宿主生命周期;管理 UI 关闭不销毁执行宿主,服务释放和应用退出才按约定清理。验证隐藏 Renderer 的调度、计时器和 Worker 执行,不根据窗口不可见推断运行不受影响。
|
||||
- 用实际 OS PID、Electron 进程指标和崩溃影响验证隔离程度。单独的 BrowserWindow/WebContents 不能直接作为独立 OS 进程或独立服务资源统计的证据;若与其他 Renderer 共享 PID,必须继续报告共享范围。
|
||||
|
||||
Embedding 保留共享 utilityProcess。查询与索引共享模型和进程的现有方式具备复用基础,优化先处理取消回收和失败重试。单请求取消继续尽量只影响该请求;进程终止属于服务级操作,必须说明其他使用方的结果。不得把每次请求超时直接等同于可以无条件杀死共享进程。
|
||||
|
||||
## 任务控制覆盖与缺口
|
||||
|
||||
[共享契约](../../src/shared/local-inference-contracts.ts)将服务状态与任务状态分开;[LocalInferenceService](../../src/main/local-inference-service.ts) 在 Main 内存登记请求,保存活动项及最近 100 条终态记录。任务的 `running` 当前包含排队、模型加载与执行,未提供统一细分阶段。该登记器是观测和控制入口,各引擎仍负责实际队列与清理。
|
||||
|
||||
| 控制维度 | 当前覆盖 | 后续评估和验收要求 |
|
||||
| --- | --- | --- |
|
||||
| 状态 | 任务有 `running/cancelling/completed/cancelled/failed`;服务另有启动、运行、停止、未知和错误状态;ASR 模型可用时服务仍为 unknown | 区分调用方已返回、执行仍活动、资源清理已确认;进程 spawn、模型 ready、请求完成不能互相替代。先增加必要证据,不预建完整阶段状态机 |
|
||||
| 所有权 | ASR 请求持有 Worker;OCR Renderer 持有共享 Worker;embedding 按目录共享 Broker 并引用计数 | 控制记录注明真实来源与宿主;缺失项目、会话关联时不猜测归属。产品 Task 的取消不能凭名称去停止整个推理服务 |
|
||||
| 取消 | ASR terminate;OCR 来源取消会终止当前 Worker;embedding 协作取消并保留执行记录 | 验证取消与自然完成竞态、重复取消及清理结果。单请求取消与共享服务停止分别定义影响,不用 UI 隐藏代替取消 |
|
||||
| 超时与容量 | ASR Worker 120 秒;OCR 两端预算与排队语义不同;embedding 默认请求 120 秒且保留取消后容量 | 分别说明排队、启动、推理、清理的计时起点和上限;调用方超时后仍在执行的工作需要有界处置,不得提前释放容量制造超额并发 |
|
||||
| 停止与清理 | 服务操作按 serviceId 串行,期间拒绝新增请求;停止校验当前活动 taskId 确认清单;向量停止等待 exit,重启不重放请求 | 清理失败保持可观察的活动/错误信息;窗口或宿主丢失需使受影响请求得到明确结果;ASR 和 OCR 的结果/清理间隙分别核对 |
|
||||
| 可观测性 | 有来源、状态、时间、可选模型和错误;向量有 PID 资源数据;管理 Modal 不显示任务历史 | 测量排队、加载、执行、取消请求到执行结束、清理确认的耗时与在途数;日志不记录音频、图片或向量原文,未知指标不补零 |
|
||||
|
||||
本文的覆盖要求用于后续问题复现和最小修复,不构成引入统一常驻服务框架、持久化执行副本或自动恢复协议的决定。范围为桌面本机引擎;远程 Agent、外部向量端点和未接入的 TTS 不因此获得同样控制能力。
|
||||
|
||||
## 资源观测口径
|
||||
|
||||
[LocalInferencePage](../../src/renderer/src/LocalInferencePage.tsx) 在启用、文档可见且 Modal 内容未被 hidden/inert 隔离时每 5 秒读取快照,跳过重叠请求,关闭时清理轮询。该刷新间隔不能用于证明短请求的峰值或清理完成时刻。
|
||||
|
||||
[向量资源采样器](../../src/main/inference-process-resources.ts) 按 transport 当前 PID 匹配 `app.getAppMetrics()` 的 Utility 条目,用累计 CPU 秒差除以单调时间间隔。100% 表示一个逻辑核心,可超过 100%;首次采样、PID/创建时间变化、间隔超过 15 秒或读数异常时重建基线。工作集从 KiB 转为字节,UI 显示 MiB。
|
||||
|
||||
向量工作集包含运行时、模型、临时缓冲区及并发使用方,不能当作模型权重大小或单任务内存。ASR Node Worker 和 OCR Web Worker 没有当前可独立归属的 OS 进程工作集;宿主进程总量不能直接填入某个服务行。未来 OCR 后台宿主也必须先核实 PID 与共享情况,再决定指标范围。显存当前未采集。
|
||||
|
||||
## 优化待办与验收
|
||||
|
||||
优先级表示建议处理顺序:P1 为执行正确性和回收问题,P2 为测量及有条件的架构选型。全部待办待开展;源码核对已完成,故障复现、修复和实机验收分别记录,不以文档完成替代实现完成。
|
||||
|
||||
| ID | 优先级与事项 | 依赖及验收条件 |
|
||||
| --- | --- | --- |
|
||||
| TC-1 | P1:embedding 取消/超时后的有界回收 | 先覆盖 `session.run` 延迟返回及不返回,观察 caller、pending、执行记录与容量。定义取消后的清理等待上限及升级条件,数值依据测量确定。普通取消不终止其他共享任务;确需服务级终止时明确受影响使用方及失败结果,等实际退出再释放容量,且不重放请求。模拟用于稳定触发条件,最终以真实引擎取消和停止路径补验 |
|
||||
| TC-2 | P1:embedding 加载失败重试 | 覆盖首次读模型或建 session 失败、随后恢复依赖的场景;选择清除失败缓存或显式重建引擎的最小方案。验收后续请求能够重新加载,并发请求不重复加载,失败中已创建的资源被清理,销毁后不再复活;不能把一次成功创建进程当作重试通过 |
|
||||
| TC-3 | P1:OCR 宿主就绪、丢失与超时预算 | 保留当前 Web Worker 即可先验证,无须等待迁移。覆盖监听安装前派发、主 Renderer 重载/崩溃、冷启动超过 Main 启动预算及排队取消。明确 bridge 就绪与 Worker ready 的区别;受影响请求有明确结果,队列能继续使用,没有静默丢失或重放;两端预算、起算点和取消后下一项派发与清理顺序一致 |
|
||||
| TC-4 | P1:ASR 结果与清理记账 | 覆盖结果先到、延迟退出、清理异常以及取消/超时与结果竞争。选择延后请求结算或单独保留清理记录的最小方案;验收资源仍活动时不会被报告为已释放,也不提前放开实际单请求容量。正常完成及异常路径均验证 Worker 最终退出 |
|
||||
| TC-5 | P2:统一测量记录与资源归属核验 | 可与 P1 复现并行,为 TC-6/TC-7 提供依据。记录冷/暖加载时间、端到端延迟、排队时长、取消至执行结束/资源回收时长、空闲与峰值工作集、CPU 时间和样本数;按 PID 对照 OS 工具,分别报告引擎、宿主与应用总量。重启后不沿用旧 PID 基线,不以 5 秒 UI 轮询代替专门测量 |
|
||||
| TC-6 | P2、条件待定:OCR 后台 Renderer 加 Worker | 依赖明确的 UI 独立执行需求、TC-3 生命周期结论及 TC-5 数据。候选验证需包含图片、多页 PDF、UI 重载时执行、后台宿主崩溃、空闲释放、应用退出、preload/IPC sender 边界和各支持平台打包资源。证实任务连续性及实际 PID 隔离后,再记录采用或保留现状的结论 |
|
||||
| TC-7 | P2、条件待定:ASR 常驻 utilityProcess | 依赖流式/高频场景成立,或 TC-5 证明重复加载达到需优化程度,并完成 TC-4。对比请求级 Worker 与候选的冷启动、暖请求、空闲内存和取消成本;若为流式,验证连续音频、结果提交和停止语义。没有测量或实际需求时继续保留请求级 Worker |
|
||||
|
||||
测量记录应包含 OS/架构、Electron 与依赖版本、模型版本、硬件、输入时长/页数/token 与批量大小、冷暖条件、请求频率和并发使用方。使用固定可重复输入,报告样本数、分布及异常,不填入未经测量的性能收益。后续验收覆盖受影响的 Windows、macOS、Linux 构建;尚未测的平台明确保留为待验。
|
||||
|
||||
本次证据为源码和已安装依赖入口检查。后续执行待办时,在所属功能进度文档记录命令、环境、真实路径结果及遗留项,并引用 TC 编号;若迁移条件未成立,记录保留现状的原因即可。
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
|
||||
- **通用设置**
|
||||
- **魔法笔记**
|
||||
- **远程项目(技术预览)**
|
||||
2. “通用设置”首个功能为“模型下载源”。
|
||||
3. 首期提供两个互斥选项:
|
||||
- `modelscope`,用户文案为 **ModelScope**,默认值。
|
||||
@@ -49,7 +49,7 @@
|
||||
下载使用新来源。
|
||||
9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由
|
||||
用户明确选择。
|
||||
10. 现有魔法笔记设置只移动到“魔法笔记”页签,不改变保存、启用或评论行为。
|
||||
10. 魔法笔记设置由应用中心与笔记页共享,不在全局设置中重复提供入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
“平台功能”现已使用两个二级页签:
|
||||
|
||||
- “通用设置”承载全局模型下载源。
|
||||
- “魔法笔记”承载显示入口、AI 评论方式和 AI 评论形式。
|
||||
- “远程项目(技术预览)”承载远程项目开关与 Agent 包管理。
|
||||
|
||||
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
- 切换来源时不影响已安装、已选择或正在使用的模型。
|
||||
- 下载失败时知道实际使用了哪个来源。
|
||||
- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。
|
||||
- 在“平台功能”中快速区分通用设置与魔法笔记。
|
||||
- 在“平台功能”的“通用设置”中选择模型下载源。
|
||||
|
||||
### 3.2 产品目标
|
||||
|
||||
@@ -166,10 +166,9 @@
|
||||
└─ 平台功能
|
||||
├─ 通用设置
|
||||
│ └─ 模型下载源
|
||||
└─ 魔法笔记
|
||||
├─ 显示魔法笔记入口
|
||||
├─ AI 评论方式
|
||||
└─ AI 评论形式
|
||||
└─ 远程项目(技术预览)
|
||||
├─ 远程项目开关
|
||||
└─ Agent 包管理
|
||||
```
|
||||
|
||||
分类页只有一个一级标题“平台功能”。页签标题不重复渲染为第二个页面标题;每个面板内部
|
||||
@@ -186,7 +185,7 @@
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{ id: 'general', label: '通用设置' },
|
||||
{ id: 'magic-notes', label: '魔法笔记' }
|
||||
{ id: 'remote-projects', label: '远程项目(技术预览)' }
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
@@ -214,7 +213,7 @@
|
||||
|
||||
未来新增页签时:
|
||||
|
||||
- 必须与“通用设置”和“魔法笔记”处于同级。
|
||||
- 必须与“通用设置”和“远程项目(技术预览)”处于同级。
|
||||
- 页签表示完整、独立的功能设置域。
|
||||
- 通用设置只放跨功能的全局行为,不成为任意设置的杂物区。
|
||||
- 页签超过可读数量时重新组织信息架构,不允许多行堆叠。
|
||||
@@ -230,7 +229,7 @@
|
||||
平台功能
|
||||
管理通用平台行为与可选工作区能力
|
||||
|
||||
[ 通用设置 ] [ 魔法笔记 ]
|
||||
[ 通用设置 ] [ 远程项目(技术预览) ]
|
||||
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
@@ -304,28 +303,11 @@
|
||||
|
||||
---
|
||||
|
||||
## 8. 魔法笔记页签
|
||||
## 8. 魔法笔记设置边界
|
||||
|
||||
现有魔法笔记卡完整移动到“魔法笔记”面板:
|
||||
|
||||
- 显示魔法笔记入口。
|
||||
- AI 评论方式。
|
||||
- AI 评论形式。
|
||||
|
||||
保持以下行为不变:
|
||||
|
||||
- 开关继续使用共享 Switch 和 `role="switch"`。
|
||||
- 评论方式和形式继续使用 `SegmentedControl`。
|
||||
- 每项继续即时保存。
|
||||
- 保存失败保留最后确认设置。
|
||||
- `onMagicNotesEnabledChange` 继续更新应用导航入口。
|
||||
|
||||
页签重构不能:
|
||||
|
||||
- 重置现有设置。
|
||||
- 在切换页签时保存或改变值。
|
||||
- 因魔法笔记入口关闭而隐藏“魔法笔记”设置页签。
|
||||
- 把页签选择误当成启用开关。
|
||||
全局设置不再提供魔法笔记页签或跳转入口。应用中心与笔记页继续共享同一表单和配置,
|
||||
保留已有启用、待办数量显示、评论方式、评论形式及业务数据。共同设置的交互与同步规则见
|
||||
[应用导航 UI 设计](../features/application-tool-navigation/ui-design.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -899,7 +881,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 默认选中“通用设置”Tab。
|
||||
- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。
|
||||
- 方向键、Home、End 和焦点恢复正确。
|
||||
- 切换到魔法笔记不改变任何设置。
|
||||
- 切换平台功能页签不改变任何设置,全局设置不显示魔法笔记入口。
|
||||
- Radio Group 具有持久 Label。
|
||||
- 选择来源时保存一次,保存期间防止重复提交。
|
||||
- 保存失败恢复原值并保留可重试错误。
|
||||
@@ -954,7 +936,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 将应用设置版本升级并迁移旧版本。
|
||||
- 在“平台功能”中增加 `PageTabs`。
|
||||
- 增加“通用设置”与模型下载源 Radio Group。
|
||||
- 把现有魔法笔记卡移动到“魔法笔记”面板。
|
||||
- 魔法笔记卡已迁入应用共同设置,全局页签已移除,见第 8 节。
|
||||
|
||||
### 阶段 2:共享目录和来源解析(已完成)
|
||||
|
||||
@@ -983,14 +965,14 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
### 19.1 产品
|
||||
|
||||
- “平台功能”显示“通用设置”和“魔法笔记”两个可访问页签。
|
||||
- “平台功能”显示“通用设置”和“远程项目(技术预览)”两个可访问页签。
|
||||
- 默认打开“通用设置”。
|
||||
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
|
||||
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
|
||||
- 切换来源不会下载、删除、切换或重新验证任何已安装模型。
|
||||
- 当前来源没有模型时,在点击下载前即可看到不可用原因。
|
||||
- 下载失败不会自动请求另一来源。
|
||||
- 魔法笔记的现有设置和入口行为保持不变。
|
||||
- 魔法笔记设置由应用中心与笔记页共享,全局设置不再提供笔记入口;已有配置值和业务数据保持不变。
|
||||
|
||||
### 19.2 数据与下载
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,内置应用通过最底部应用中心
|
||||
访问,并可常驻主导航;系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,知识库和智能心跳作为固定内置应用始终启用并显示;
|
||||
魔法笔记和本机推理可通过应用管理控制启用、常驻及排序。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
|
||||
商店入口、推荐位或安装流程。
|
||||
|
||||
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
|
||||
本轮更新实施计划与范围,旧版功能要求、验收和配套设计仍需按新计划同步,此项列为实现前的第一步。
|
||||
本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。侧栏依次显示知识库、智能心跳,再显示按偏好过滤的可选应用。两个固定应用在管理 Modal 中仅提供“打开”,不参与启用、常驻或排序,不增设通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。
|
||||
|
||||
新增计划包括 [私有化市场与 YAML 交换](./prd.md#fr-15-私有化市场与-yaml-交换后续):
|
||||
通过市场 URL 获取应用,支持 `.yml` 导入导出,未来由 ShareServer 提供私有目录与分发。
|
||||
[本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)可独立于商店交付,拟作为
|
||||
可常驻左侧的内置管理应用,区分受管进程、外部服务和进程内模型。两项均为需求记录,
|
||||
未实现,相关后续验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
[本机模型管理入口](./prd.md#fr-16-本机模型管理入口新增计划项)已独立于商店接入,作为
|
||||
可常驻左侧的“本机推理”内置应用,直接查看语音、OCR、向量等本机服务状态与可用资源指标,不显示任务历史。向量服务显示独立进程的 CPU 和工作集内存;ASR、OCR 与宿主共享进程,逐行说明无法独立统计的原因。
|
||||
本轮已接入 `local-inference` 管理 Modal、类型化 IPC、真实请求登记及引擎管理;实际支持清单见
|
||||
[技术设计](./technical-design.md#111-本机推理任务与执行服务),市场仍为后续需求。
|
||||
相关验收见 [User Stories](./user-stories.md#7-后续需求场景)。
|
||||
|
||||
项目活动汇总独立于上述应用中心计划,范围见 [FR-14](./prd.md#fr-14-项目活动汇总)。
|
||||
计数规则由[活动逻辑](./logic-design.md#9-项目活动汇总)定义,入口与级联菜单见
|
||||
@@ -21,6 +23,8 @@
|
||||
|
||||
## 文档导航
|
||||
|
||||
2026-09-16 后续应用组织决策:计划将现有“智能心跳”入口整合、升级为“监督者”,内部包含工作回顾和故事线图谱。未来市场复用同一应用身份,不增加平级重复入口。该更名与整合尚未实施;当前智能心跳为固定应用,不提供应用级启用、常驻或排序。目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、产品边界、功能要求与产品验收 |
|
||||
@@ -28,7 +32,7 @@
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
| [实施进度](./progress.md) | 项目活动级联的生产实现与验证记录 |
|
||||
| [实施进度](./progress.md) | 应用中心、设置及项目活动级联的实现与验证记录 |
|
||||
| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 |
|
||||
|
||||
Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无需依赖或构建。
|
||||
@@ -36,7 +40,7 @@ Demo 可直接在支持原生 dialog、popover 的现代浏览器中打开,无
|
||||
A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单独演示未来私有市场。
|
||||
模型服务、资源指标和工具内容均为演示数据,不执行真实安装、模型加载或进程操作。
|
||||
窄屏上下排列侧栏与主内容,应用页面隐藏输入区,对话页面显示输入区。
|
||||
模型管理与 F 方案均为计划预览,不代表正式功能已交付,也不纳入首期验收。
|
||||
Demo 的模型管理和 F 方案均使用模拟数据;正式本机推理 Modal 的交付范围以技术清单和实施进度为准,旧页面原型不作为 Modal 验收依据。
|
||||
底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、
|
||||
知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名
|
||||
附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止,
|
||||
@@ -48,11 +52,13 @@ A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单
|
||||
进入会话层,并提供返回按钮。页面上方可切换“有待处理、仅运行中、仅当前项目运行、全部
|
||||
空闲”四种场景。项目与会话均为演示数据;该级联交互已接入正式产品活动入口,范围与
|
||||
验证记录见[实施进度](./progress.md)。
|
||||
按最新讨论,Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。上方正式文档仍保留原设计,待方案确定后同步。
|
||||
Demo 已移除左侧系统工具快捷区:文件、终端、浏览器和资源监控只在右侧
|
||||
工作栏 Tab 中演示,不列入应用中心。旧原型仍直接打开整窗 Modal,其中使用可搜索应用列表。
|
||||
|
||||
Demo 待同步更正:本节仅描述旧原型,不是生产行为或验收依据。原型直接打开 Modal,以及知识库和智能心跳的启停、常驻、排序、通用设置,均已过时;生产采用上述轻量菜单及两个固定应用规则。本轮不修改 Demo 文件。
|
||||
|
||||
六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用
|
||||
上下箭头排序、移除入口。魔法笔记、知识库、智能心跳及计划预览“本机模型”默认全部启用且常驻。
|
||||
上下箭头排序、移除入口。魔法笔记、知识库、智能心跳及计划预览“本机推理管理”默认全部启用且常驻。
|
||||
取消常驻仅移除导航捷径,仍可从应用中心打开;关闭应用隐藏业务入口,应用中心保留设置与
|
||||
重新开启入口,并记住常驻偏好。当前应用页关闭后显示不可用状态及设置恢复按钮。
|
||||
知识库关闭时同步隐藏侧栏入口、会话选项中的知识范围和检索方式、已选知识标签;重新开启
|
||||
@@ -62,12 +68,13 @@ A–E 仅展示内置应用,没有商店入口、推荐或安装操作;F 单
|
||||
所有内置应用提供应用中心、应用页面和全局设置入口,均打开同一应用设置面板,分别控制
|
||||
启用与常驻。魔法笔记面板另含显示未完成待办数量、AI 评论方式
|
||||
(即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示
|
||||
魔法笔记入口”在原型中与“常驻左侧菜单”统一。启用、常驻、待办数量显示默认开启;
|
||||
魔法笔记入口”不在原型中重复提供。正式迁移保留其业务启用值,常驻使用独立配置。启用、常驻、待办数量显示默认开启;
|
||||
评论方式默认即时,评论形式默认长评 + 要点。侧栏待办数为固定的 3 项演示数据。
|
||||
修改即时生效于原型,不调用 AI 或写入真实应用设置。
|
||||
|
||||
本机模型主内容区展示紧凑服务列表,包含模型、用途、引擎、管理方式、状态、占用和使用方。
|
||||
受管进程模拟启动、停止、重启;外部服务仅提供连接配置预览;进程内模型模拟加载与释放。
|
||||
此 Demo 中的任务、进度、使用方与服务内存为旧方案模拟内容;正式 Modal 已收窄为服务状态列表,见 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
引擎和管理方式收进详情;受管进程模拟启动、停止、重启,进程内模型模拟加载与释放。
|
||||
服务停止或重启后任务标为中断,重新启动不自动恢复任务;任务级取消仍待实际引擎接入。
|
||||
影响使用方的操作先显示就地确认,取消保留原状态;隐藏或关闭管理应用不改变服务状态。
|
||||
|
||||
F 提供私有市场 URL、模拟获取目录、模拟不可达与重试,不发起网络请求或切换公共来源。
|
||||
@@ -97,7 +104,7 @@ Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 |
|
||||
| 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 |
|
||||
| 应用 | 可显示在主导航区的功能;知识库、智能心跳和魔法笔记打开一级页面,本机推理打开保留底层工作区的 Modal |
|
||||
| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,包括文件、终端、浏览器和资源监控,由右侧助手工作栏 Tab 承载 |
|
||||
| 插件应用 | 未来由插件提供的一级页面入口 |
|
||||
| 插件工具 | 未来由插件提供的辅助面板或命令入口 |
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联 User Stories | [User Stories](./user-stories.md) |
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
1. “当前对话”“运行记录”和“设置”始终是系统入口。
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角只提供快捷入口,工具内容仍由助手工作栏承载。
|
||||
5. 隐藏应用不等于停用功能,也不删除、迁移或清理业务数据。
|
||||
6. 快捷入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
|
||||
5. 魔法笔记、本机推理的启用、常驻及顺序是独立状态。知识库和智能心跳始终启用并固定显示,不参与这些状态;取消可选应用常驻不关闭功能,关闭不删除数据或导航偏好。
|
||||
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
|
||||
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
|
||||
|
||||
## 2. 入口模型
|
||||
|
||||
@@ -25,14 +27,21 @@
|
||||
type NavigationContribution =
|
||||
| {
|
||||
type: 'system-entry'
|
||||
id: 'chat' | 'activity' | 'settings'
|
||||
id: 'chat' | 'activity' | 'application-center' | 'settings'
|
||||
placement: 'primary-start' | 'primary-end' | 'footer'
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
id: 'knowledge' | 'heartbeat'
|
||||
source: 'builtin'
|
||||
visible: boolean
|
||||
fixed: true
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'magic-notes' | 'local-inference'
|
||||
source: 'builtin'
|
||||
enabled: boolean
|
||||
pinned: boolean
|
||||
}
|
||||
| {
|
||||
type: 'system-tool'
|
||||
@@ -41,7 +50,8 @@ type NavigationContribution =
|
||||
}
|
||||
```
|
||||
|
||||
该模型只覆盖首期入口。插件贡献模型随插件 Host、权限和生命周期另行设计。
|
||||
该模型是首期的逻辑投影,启用值来自各应用的唯一业务配置,常驻及顺序来自导航偏好。
|
||||
应用清单为魔法笔记、知识库、智能心跳、本机推理,打开无需安装。市场不加入本轮清单。
|
||||
|
||||
## 3. 主导航组合规则
|
||||
|
||||
@@ -49,35 +59,62 @@ type NavigationContribution =
|
||||
|
||||
```text
|
||||
当前对话
|
||||
可见的内置应用
|
||||
知识库(固定显示)
|
||||
智能心跳(固定显示)
|
||||
可选应用(按保存顺序,仅显示启用且常驻项)
|
||||
运行记录
|
||||
```
|
||||
|
||||
当前对话和运行记录不参与排序。首期内置应用默认顺序为魔法笔记、知识库、智能心跳;显隐
|
||||
设置只从中移除对应项,不改变固定入口的位置。
|
||||
当前对话、知识库、智能心跳和运行记录不参与排序。可选应用默认顺序为魔法笔记、本机推理,默认均常驻。两个固定应用始终位于可选应用之前,过滤可选项不留下空白。
|
||||
常驻、取消常驻和排序按设备保存,重启后恢复。排序只发生在应用区,不移动固定系统入口。
|
||||
保存完整可选应用顺序,关闭或取消常驻只过滤主导航,不删除顺序记录;重新开启或常驻时按
|
||||
该顺序恢复。管理列表编辑完整可选应用顺序;上下移动和拖动共用同一顺序写入,两个固定应用不作为拖动项或目标。
|
||||
|
||||
### 3.1 应用可见性与业务状态
|
||||
|
||||
| 导航可见 | 功能启用 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 是 | 是 | 显示入口并允许进入 |
|
||||
| 否 | 是 | 隐藏入口,后台和数据保持原状 |
|
||||
| 是 | 否 | 不显示不可用入口;设置页保留启用路径 |
|
||||
| 否 | 否 | 不显示入口,保留符合该功能自身规则的数据 |
|
||||
下表仅适用于可选应用。知识库和智能心跳始终可用,管理 Modal 中仅有“打开”,保留原工作区实际配置,不增加通用空设置页。心跳单条计划 enabled 仍按原业务规则控制计划。
|
||||
|
||||
“功能启用”由各应用现有产品设置负责。例如魔法笔记启停仍使用原设置;导航偏好不能覆盖它。
|
||||
| 常驻偏好 | 功能启用 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 是 | 是 | 主导航显示,可从主导航或应用中心打开 |
|
||||
| 否 | 是 | 主导航隐藏,仍可从应用中心打开,后台和数据保持原状 |
|
||||
| 是 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住常驻及顺序 |
|
||||
| 否 | 否 | 主导航、应用菜单和业务入口隐藏,管理 Modal 保留设置,记住未常驻状态 |
|
||||
|
||||
管理 Modal 浏览和搜索包含关闭的可选应用,搜索不修改启用或常驻。轻量菜单仅显示已启用项,点击行先关闭菜单;本机推理打开独立 Modal,其余应用请求主内容区导航。管理 Modal 的“打开”和常驻入口遵守同一规则。打开已启用应用不自动常驻;
|
||||
取消当前应用的常驻不关闭当前页面或本机推理 Modal。关闭应用后不能从旧入口继续执行新的业务操作。
|
||||
停用时仍允许管理常驻偏好,界面说明重新开启后才会显示入口。
|
||||
|
||||
### 3.2 关闭应用与恢复
|
||||
|
||||
关闭与恢复仅适用于可选应用,知识库页面、会话控件及关联,以及智能心跳入口、页面和计划 enabled 不受可选应用偏好影响。
|
||||
|
||||
| 对象 | 关闭后的结果 | 重新开启后的结果 |
|
||||
| --- | --- | --- |
|
||||
| 当前已打开应用页或本机推理 Modal | 原位置显示“应用已关闭”的不可用状态,提供应用设置恢复入口;保留内容状态及草稿,不强制跳转 | 恢复可用性;仍打开时恢复内容,已关闭或离开则不自动打开 |
|
||||
| 已在执行的请求及后台操作 | 沿用功能自身的取消、完成和清理生命周期;本次应用开关不新增取消策略 | 不自动恢复、中断后重跑或补发任务 |
|
||||
| 数据、常驻及顺序 | 保留原值,业务入口按启用状态过滤 | 按保存的常驻及顺序恢复入口,不把未常驻应用自动常驻 |
|
||||
|
||||
知识请求及单个知识库实例停用、配置修改或删除继续遵守知识库自身规则,没有应用级禁用判断。
|
||||
|
||||
### 3.3 魔法笔记设置迁移
|
||||
|
||||
魔法笔记设置迁入应用中心的共同设置页;笔记页面定位到同一页,全局设置移除该入口,
|
||||
编辑同一配置。启用、常驻、显示未完成待办数量分别控制功能、导航和徽标,不保留重复的
|
||||
“显示魔法笔记入口”开关。
|
||||
|
||||
未配置的应用启用、常驻及魔法笔记相关布尔项默认开启;已有明确保存的 `false` 必须保留。
|
||||
AI 评论方式和评论形式继续为单选,沿用现有默认值“即时”和“长评 + 要点”,不能解释为
|
||||
全部模式同时启用。迁移保留既有单选值及业务数据。关闭数量显示只隐藏徽标,不修改待办。
|
||||
|
||||
## 4. 系统工具打开规则
|
||||
|
||||
### 4.1 通用流程
|
||||
|
||||
```text
|
||||
用户激活工具按钮
|
||||
-> 校验按钮仍可用
|
||||
-> 向助手工作栏发送打开意图
|
||||
-> 工作栏按应用实例策略查找或创建实例
|
||||
-> 展开工作栏
|
||||
-> 激活目标实例
|
||||
用户展开右侧工作栏
|
||||
-> 选择已有 Tab,或从“+”目录选择工具
|
||||
-> 已有 Tab 按实例 ID 激活;目录按单实例或多实例策略创建或激活
|
||||
-> 把焦点移入面板标题或首个主要控件
|
||||
```
|
||||
|
||||
@@ -85,31 +122,27 @@ type NavigationContribution =
|
||||
|
||||
### 4.2 决策表
|
||||
|
||||
| 工具 | 匹配实例存在 | 匹配实例不存在 | 上下文不可用 |
|
||||
| 工具 | 选择已有 Tab | 从“+”目录选择 | 上下文不可用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 文件 | 激活不可关闭的单实例并保留其跟随或固定状态 | 仅在迁移或布局修复时补回 `workspace` | 打开面板并显示目录修复入口 |
|
||||
| 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home |
|
||||
| 浏览器 | 激活绑定当前 Conversation 的最近使用实例 | 创建绑定当前 Conversation 的新实例 | 激活最近使用实例;不存在时创建待绑定实例 |
|
||||
| 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 |
|
||||
| 文件 | 激活不可关闭单实例并保留跟随或固定状态 | 聚焦 `workspace`;仅迁移或布局修复时补回 | 跟随模式无有效项目目录或默认工作区时显示修复入口 |
|
||||
| 终端 | 激活选中的确切实例,保留执行空间和历史状态 | 每次创建绑定当前执行空间的额外实例 | 新建时无项目使用本机 Home;SSH 失败保留远端目标与诊断 |
|
||||
| 浏览器 | 激活选中的确切实例,保留绑定目标和页面 | 每次创建绑定当前 Conversation 的额外实例 | 新建时无 Conversation 创建待绑定空实例 |
|
||||
| 资源监控 | 激活本机 `resources` 单实例 | 已存在则聚焦,否则创建 | 部分指标不可用时显示原因并保留其余指标 |
|
||||
|
||||
文件保持单一实例原有绑定。浏览器快捷入口按当前 Conversation 匹配最近使用实例,而工作栏
|
||||
“+”始终新建浏览器实例。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认
|
||||
工作区或当前会话。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
|
||||
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
|
||||
“+”或终端面板中的既有创建命令。
|
||||
选择已有 Tab 不按当前项目或 Conversation 查找替代实例,也不重绑目标。文件的跟随模式
|
||||
仍按工作栏自身规则更新上下文;固定目标失效时显示该目标的修复状态,不静默回退。
|
||||
终端 `closing`、`exited`、`interrupted`、`failed` Tab 保留原状态,选择它们不自动重建。
|
||||
终端和浏览器通过“+”新建时不按目标去重,不恢复旧快捷按钮的最近匹配实例复用行为。
|
||||
|
||||
### 4.3 选中与状态
|
||||
|
||||
工具按钮满足以下条件时显示选中:助手工作栏已展开,且当前活动实例的 `appId` 与工具映射
|
||||
一致。只有实例存在但未激活时不显示选中。
|
||||
工作栏按确切实例 ID 表达 Tab 选中状态,同类型其他实例不能同时显示为选中。“+”是普通
|
||||
按钮,不是 Tab。主侧栏和应用中心不显示工具选中状态或工具徽标。
|
||||
|
||||
徽标优先级为:等待用户处理 > 错误 > 运行中 > 无徽标。同一按钮只显示一个视觉徽标,
|
||||
徽标优先级为:等待用户处理 > 错误 > 运行中 > 无徽标。同一 Tab 只显示一个视觉徽标,
|
||||
可访问名称包含完整状态。
|
||||
|
||||
资源监控首期不在后台采样,因此不显示负载告警徽标。只有资源面板是工作栏当前活动实例且
|
||||
工作栏已展开时,资源按钮才显示选中状态。
|
||||
资源监控首期不在后台采样,因此不显示负载告警徽标。后台事件不自动展开工作栏或切换 Tab。
|
||||
|
||||
### 4.4 资源采样状态
|
||||
|
||||
@@ -134,39 +167,68 @@ warming/loading/live/partial/error -> inactive
|
||||
|
||||
## 5. 设置保存规则
|
||||
|
||||
应用导航偏好与应用业务启停分开保存。Switch 每次切换立即提交完整目标集合;请求完成前禁用
|
||||
三个应用显隐 Switch,下一次操作基于最新确认结果提交,防止并发更新互相覆盖。
|
||||
应用导航偏好与业务启停使用独立字段,通过同一应用设置保存链路提交。常驻、排序、启用和
|
||||
魔法笔记设置更新串行执行;前一请求确认前,所有入口中会修改这组设置的控件暂不可操作。
|
||||
每次只提交本次变更,Main 在最新已保存设置上合并并原子写入;顺序提交完整目标顺序。
|
||||
持久化成功后由 Store 发布完整权威快照,应用中心、主导航、当前应用页、会话和所有设置入口同步采用,工具配置写入也走同一通知链路。每次重开中心先刷新并锁定修改;较新的设置事件使在途旧读取及旧保存响应失效,不能覆盖新快照。
|
||||
|
||||
保存失败时:
|
||||
|
||||
- 主导航和 Switch 恢复最后一次成功保存的偏好。
|
||||
- 设置行显示可重试错误。
|
||||
- 不回滚或修改各应用的业务状态。
|
||||
- 所有界面保留最后一次确认的配置,包括启用、常驻、顺序、徽标和会话入口;不提前运行关闭或恢复副作用。
|
||||
- 发起操作的设置行或排序区域显示就地可重试错误,保留焦点及编辑上下文,不重复显示同一错误通知。
|
||||
- 若响应中断导致保存结果未知,先重读权威设置再开放修改;不能把本地旧值当成回滚成功,也不自动重发变更。
|
||||
|
||||
## 6. 与需求的对应关系
|
||||
|
||||
| 逻辑 | PRD | User Stories |
|
||||
| --- | --- | --- |
|
||||
| 固定入口不变量 | FR-1、FR-2 | US-A1、US-A2 |
|
||||
| 应用显隐和失败恢复 | FR-3、FR-4、FR-11、FR-12 | US-B1、US-B2、US-B4、US-B5 |
|
||||
| 工具打开决策表 | FR-5 至 FR-10 | US-C1 至 US-C7 |
|
||||
| 应用常驻和失败恢复 | FR-3、FR-4、FR-11、FR-12 | US-B1、US-B2、US-B4、US-B5 |
|
||||
| 应用中心、常驻顺序 | FR-3、FR-4、FR-5、FR-11、FR-12;PRD 第 8 节 | US-B6、US-B7 |
|
||||
| 可选应用启用恢复、知识库和智能心跳固定可用 | FR-3、FR-4、FR-12;PRD 第 8 节补充要求 | US-B8 |
|
||||
| 魔法笔记迁移及共同配置 | FR-11、FR-12;PRD 第 8 节补充要求 | US-B9 |
|
||||
| 工具打开决策表 | FR-6 至 FR-10 | US-C1 至 US-C7 |
|
||||
| 资源采样状态 | FR-9 | US-C6 |
|
||||
| 无障碍与窄窗口 | FR-13 | US-D1、US-D2 |
|
||||
| 活动聚合、完成通知与查看清除 | FR-14 | US-E1、US-E2、US-E3 |
|
||||
| 后续私有市场与 YAML | FR-15 | US-F1、US-F2 |
|
||||
| 服务状态、资源可用性、服务生命周期 | FR-16 | US-F3、US-F5;US-F4 入口已移除 |
|
||||
|
||||
稳定 ID 保留原编号。US-B6 为应用中心,US-B7 为顺序,US-B8 为启用联动,US-B9 为笔记迁移;
|
||||
US-F3 为服务概览,US-F4 为已移除的任务取消入口,US-F5 为服务生命周期。PRD 旧 FR-5 至 FR-10 和
|
||||
User Stories 已同步上述入口规则,工具实例行为沿用右侧工作栏契约。
|
||||
|
||||
## 7. 逻辑完整性评估
|
||||
|
||||
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具匹配与显式多实例差异、本机 Home、
|
||||
托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他
|
||||
工具面板内部的取消、超时、
|
||||
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
|
||||
首期覆盖应用中心访问、启用与常驻组合、顺序恢复、默认值与旧值迁移、共同设置、保存失败、
|
||||
知识库和智能心跳固定入口、可选应用已打开页面恢复,以及右侧实例选择、本机 Home、托管 SSH、无会话、
|
||||
目录失效、资源部分可用与采样停止。工具内部取消、超时、连接恢复及业务后台生命周期由
|
||||
相应功能负责,本功能不复制状态机。底部入口打开轻量菜单,“管理应用”才进入与设置相同的整窗 Modal。实现与验证的剩余范围见[实施进度](./progress.md)。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
- 插件工具上线后,快捷栏允许固定的最大数量及溢出规则。
|
||||
- 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。
|
||||
后续市场 URL、YAML 交换、ShareServer 目录与分发、插件注册及安装不进入首期。其协议、
|
||||
扩展身份与安装生命周期由市场功能另行确定;内置应用常驻及排序不依赖这些决策。
|
||||
|
||||
这些决策不影响首期四个系统工具的固定入口和内置应用显隐实现。
|
||||
### 8.1 本机推理管理
|
||||
|
||||
FR-16 独立于市场排期,作为可常驻的管理 Modal。所有打开入口只改变 Modal 打开状态,不改变底层工作区、项目、会话或草稿;关闭时卸载 Modal,服务生命周期独立。US-F3 只有服务列表,展示名称、状态、引擎、模型及错误。共享服务只展示一次;资源按独立服务进程归属,CPU 预热与指标不可用分别给出原因,不填 `0`,不将宿主进程占用分摊给共享线程。采样与单位见[技术设计](./technical-design.md)。ASR 没有 Worker 就绪观测,显示按需运行且状态未知;活动请求不覆盖服务状态。OCR 状态来自 Renderer Worker,向量状态来自 Broker。
|
||||
|
||||
US-F4 的 Modal 单任务取消入口已移除。后端取消行为保留:Worker 确认或退出前仍计入活动任务和停止影响,即使调用方已收到取消或超时错误;任务先完成则保留真实完成结果,不通过终止共享服务模拟单任务取消。
|
||||
|
||||
| 服务管理方式 | 可提供操作 | 结果要求(US-F5) |
|
||||
| --- | --- | --- |
|
||||
| GoodBuddy 受管子进程 | 引擎已支持的启动、停止、重启 | 共用同一服务实例;操作成功由真实进程和服务就绪状态确认 |
|
||||
| 外部服务 | 在模型与连接设置中管理 | 不列入本机服务,不推断本机归属 |
|
||||
| 进程内模型 | 引擎实际支持的加载、释放 | 不显示虚构 PID 或进程启动、停止状态 |
|
||||
|
||||
停止、重启或释放均先打开紧凑的嵌套影响确认,列出任务和影响;无已登记活动任务时说明执行前再次核对。取消确认不改变运行状态。
|
||||
确认存在时禁止关闭底层管理 Modal;提交期间两层均不能关闭,重复操作被拒绝。未提交时 Escape 只关闭确认,确认关闭后才可用 Escape 关闭管理 Modal。焦点规则由 [UI 设计](./ui-design.md#91-本机推理管理)定义。
|
||||
实际操作失败关闭旧确认、显示错误并刷新快照;刷新期间或失败时禁用服务操作。再次操作重新取得当前任务清单并要求确认,避免重复提交旧清单。
|
||||
再次启动不重放已中断任务;关闭管理 Modal、关闭管理应用或取消常驻不停止服务,卸载模型文件为独立操作。
|
||||
当前向量服务允许确认影响后停止,并拒绝确认清单未包含的新任务;OCR 仅空闲时允许释放。
|
||||
ASR 按请求加载与释放,支持独立取消;TTS 尚无执行服务。具体适配边界以
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务)为准。活动请求仅在影响确认中展示,主列表不展示历史。应用退出清理沿用各引擎原有路径。
|
||||
|
||||
## 9. 项目活动汇总
|
||||
|
||||
|
||||
@@ -122,6 +122,27 @@ dialog[data-variant="grid"] #catalog-items { display: grid; grid-template-column
|
||||
.sidebar > * { flex-shrink: 0; }.sidebar .history { flex: 1 1 80px; min-height: 0; }.sidebar .shortcuts { flex-shrink: 1; min-height: 80px; }
|
||||
.sidebar .resident-apps { margin-top: 12px; min-height: 96px; flex-shrink: 1; display: flex; flex-direction: column; max-height: 240px; }.resident-apps .section-label { margin-bottom: 4px; flex-shrink: 0; }#resident-list { overflow: auto; min-height: 0; }.resident-row { display: flex; align-items: center; }.resident-row > button { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: 1; min-width: 0; text-align: left; }.resident-row > button span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.resident-row [aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }.resident-row[draggable="true"] { cursor: grab; }.resident-row .edit-actions { flex-shrink: 0; }.all-apps { display: flex; justify-content: space-between; padding: 6px 10px; font-size: 11px; color: var(--text-secondary); flex-shrink: 0; }.empty-apps { padding: 8px 10px; font-size: 11px; color: var(--text-secondary); }.app-placement { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }.app-placement button { font-size: 11px; padding: 6px 8px; }.app-placement button[aria-pressed="true"] { border: 1px solid var(--accent-selected); }.app-catalog-row { align-items: flex-start; }.app-catalog-row > svg { margin-top: 4px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
.workspace { min-height: 0; }
|
||||
.workspace-header { flex: 0 0 auto; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
#page-label { overflow-wrap: anywhere; }
|
||||
.conversation { min-height: 0; overflow: auto; }
|
||||
.conversation.app-page { justify-content: flex-start; padding: 24px; max-width: 960px; gap: 12px; }
|
||||
.app-page h1 { font-size: 24px; }
|
||||
.model-list { width: 100%; }
|
||||
.model-service { padding: 16px 0; border-bottom: 1px solid var(--border-default); }
|
||||
.model-service p, .model-service details { color: var(--text-secondary); font-size: 12px; }
|
||||
.model-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.model-actions button, .inline-confirm button { border: 1px solid var(--border-default); }
|
||||
.inline-confirm { padding: 12px; margin: 8px 0; background: var(--accent-subtle); border: 1px solid var(--accent-selected); border-radius: var(--radius-control); }
|
||||
#market-preview { padding: 12px 0; border-bottom: 1px solid var(--border-default); }
|
||||
#market-preview label { display: block; margin: 8px 0; }
|
||||
#market-preview textarea, #market-preview select { width: 100%; padding: 8px; font: inherit; background: var(--surface-subtle); color: var(--text-primary); border: 1px solid var(--border-default); border-radius: var(--radius-control); }
|
||||
#market-preview textarea { resize: vertical; }
|
||||
#market-preview p, #import-preview { font-size: 12px; overflow-wrap: anywhere; }
|
||||
#market-preview summary, .model-service summary { cursor: pointer; }
|
||||
.note-segments, .center-switch, .tool-tabs { flex: 0 0 auto; }
|
||||
.center-app-info { min-width: 0; overflow-wrap: anywhere; }
|
||||
dialog, dialog::backdrop { -webkit-app-region: no-drag; }
|
||||
.launcher-trigger { display: flex; align-items: center; gap: 8px; width: 100%; padding: 12px 8px; border: 1px solid var(--border-default); background: var(--surface-raised); margin-bottom: 12px; font-size: 12px; }.launcher-trigger kbd { margin-left: auto; font: 10px var(--font-family-ui); color: var(--text-secondary); white-space: nowrap; }
|
||||
[data-variant="launcher"] .shortcut-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }[data-variant="launcher"] .shortcut-button { flex-direction: column; padding: 8px 0; }[data-variant="launcher"] .icon-tile { height: 24px; }[data-variant="launcher"] .store-link { border-top: 1px solid var(--border-default); border-radius: 0; }
|
||||
[data-variant="favorites"] .section-label { padding-left: 0; }[data-variant="favorites"] .shortcut-list { gap: 0; }[data-variant="favorites"] .shortcut-button { justify-content: flex-start; padding: 8px 12px; }[data-variant="favorites"] .item-name { font-size: 12px; }[data-variant="favorites"] .shortcut-item { display: flex; align-items: center; }[data-variant="favorites"] .edit-actions { margin-left: auto; }[data-variant="favorites"] .store-link { margin-top: 12px; border: 1px dashed var(--border-default); }.count { padding: 0 6px; color: var(--text-secondary); }
|
||||
@@ -130,4 +151,4 @@ dialog[data-variant="grid"] #catalog-items { display: grid; grid-template-column
|
||||
.catalog-pack { padding: 20px 0; border-bottom: 1px solid var(--border-default); }.catalog-pack h3 { margin: 8px 0; font-size: 18px; font-weight: 550; }.pack-art { display: grid; place-items: center; float: right; width: 56px; height: 56px; border: 1px solid var(--accent-selected); background: var(--accent-subtle); color: var(--accent); border-radius: var(--radius-card); }.pack-art svg { width: 28px; height: 28px; }.pack-includes { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }.pack-includes span { padding: 4px 8px; font-size: 11px; background: var(--surface-subtle); border-radius: var(--radius-control); }.pack-includes svg { width: 14px; height: 14px; }.catalog-pack > button { border: 1px solid var(--accent-selected); color: var(--accent); background: var(--accent-subtle); }
|
||||
dialog[data-mode="launch"] { width: min(640px, calc(100% - 32px)); }dialog[data-mode="launch"] .catalog-row:focus-within { background: var(--accent-subtle); }.catalog-row { padding-left: 8px; padding-right: 8px; }
|
||||
@media (max-width: 1080px) { body { padding: 16px; }.app { position: relative; }.workbar { position: absolute; right: 0; top: 52px; bottom: 0; width: min(320px, 100%); box-shadow: -8px 0 24px var(--border-default); }.lab-header { flex-wrap: wrap; gap: 12px; }.design-note > span:last-child { display: none; }.conversation { padding: 32px 24px; } }
|
||||
@media (max-width: 640px) { body { padding: 12px; }.lab-header > div:first-child { flex: 1; }.variants { order: 3; width: 100%; }.variants button { flex: 1; padding: 8px 4px; font-size: 12px; }.design-note { align-items: flex-start; gap: 12px; }.app { height: auto; min-height: 0; }.app .sidebar { width: 100%; flex: 0 0 auto; border: 0; padding-top: 12px; }.brand { padding-bottom: 12px; }.app .history { display: none; }.app .workspace { display: flex; min-height: 320px; border-top: 1px solid var(--border-default); }.app .workspace .composer { display: block; }.composer-area { margin: 12px 12px 4px; margin-top: auto; }.composer-toolbar { gap: 2px; }.composer-select select { max-width: 108px; font-size: 11px; }.composer-toolbar button { font-size: 11px; padding: 6px; }.composer-toolbar .composer-icon { width: 28px; }.composer-meta { flex-wrap: wrap; gap: 0; }.shortcut-list { max-height: 300px; overflow: auto; }.lab-footer { align-items: flex-start; }.lab-footer button { white-space: nowrap; }.workbar { top: 0; width: 100%; }.design-note p { font-size: 11px; } }
|
||||
@media (max-width: 640px) { body { padding: 12px; }.lab-header > div:first-child { flex: 1; }.variants { order: 3; width: 100%; }.variants button { flex: 1; padding: 8px 4px; font-size: 12px; }.design-note { align-items: flex-start; gap: 12px; }.app { height: auto; min-height: 0; }.app .sidebar { width: 100%; flex: 0 0 auto; border: 0; padding-top: 12px; }.brand { padding-bottom: 12px; }.app .history { display: none; }.app .workspace { display: flex; min-height: 320px; border-top: 1px solid var(--border-default); }.app .workspace .conversation { display: flex; overflow: visible; padding: 16px; }.app .workspace .composer { display: block; }.composer-area { margin: 12px 12px 4px; margin-top: auto; }.composer-toolbar { gap: 2px; }.composer-select select { max-width: 108px; font-size: 11px; }.composer-toolbar button { font-size: 11px; padding: 6px; }.composer-toolbar .composer-icon { width: 28px; }.composer-meta { flex-wrap: wrap; gap: 0; }.shortcut-list { max-height: 300px; overflow: auto; }.lab-footer { align-items: flex-start; }.lab-footer button { white-space: nowrap; }.workbar { top: 0; width: 100%; }.design-note p { font-size: 11px; } }
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
<button data-variant="cards" aria-pressed="false">C · 应用预览</button>
|
||||
<button data-variant="launcher" aria-pressed="false">D · 搜索启动台</button>
|
||||
<button data-variant="favorites" aria-pressed="false">E · 常驻管理</button>
|
||||
<button data-variant="scenes" aria-pressed="false">F · 商店发现</button>
|
||||
<button data-variant="scenes" aria-pressed="false">F · 私有市场预览</button>
|
||||
</div>
|
||||
<button id="theme">切换深色</button>
|
||||
</header>
|
||||
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
|
||||
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 全部状态仅在内存中,刷新重置</span></div>
|
||||
<div class="project-lab"><label>项目活动演示 <select id="project-scenario"><option value="waiting">有待处理</option><option value="running">仅运行中</option><option value="current">仅当前项目运行</option><option value="idle">全部空闲</option></select></label><span>有活动时显示摘要,悬停或点击项目向右展开会话。</span></div>
|
||||
<main class="app" data-variant="dock">
|
||||
<aside class="sidebar" aria-label="主侧栏">
|
||||
@@ -63,8 +63,8 @@
|
||||
<div id="composer-options" popover aria-label="会话选项">
|
||||
<header><strong>会话选项</strong><button popovertarget="composer-options" popovertargetaction="hide" aria-label="关闭会话选项">×</button></header>
|
||||
<label class="option-row">专家<select id="composer-expert"><option value="">默认助手</option><option>产品顾问</option><option>代码审查专家</option></select></label>
|
||||
<fieldset><legend>知识范围</legend><label><input type="checkbox" data-knowledge="产品文档"> 产品文档</label><label><input type="checkbox" data-knowledge="团队知识"> 团队知识</label></fieldset>
|
||||
<label class="option-row">检索方式<select id="composer-retrieval"><option value="auto">自动检索</option><option value="always">每次检索</option></select></label>
|
||||
<fieldset data-knowledge-control><legend>知识范围</legend><label><input type="checkbox" data-knowledge="产品文档"> 产品文档</label><label><input type="checkbox" data-knowledge="团队知识"> 团队知识</label></fieldset>
|
||||
<label class="option-row" data-knowledge-control>检索方式<select id="composer-retrieval"><option value="auto">自动检索</option><option value="always">每次检索</option></select></label>
|
||||
<div id="runtime-options"><label class="option-row"><span id="agent-label">Agent</span><select id="composer-agent"><option value="">默认</option><option value="plan">Plan</option><option value="review">Review</option></select></label><label class="option-row">快捷操作<select id="composer-command"><option value="">无</option><option value="prompt">填入总结模板</option><option value="command">原生命令 /init</option></select></label></div>
|
||||
<p class="muted">非默认选项会显示在输入框上方。</p>
|
||||
</div>
|
||||
@@ -77,7 +77,16 @@
|
||||
</main>
|
||||
<footer class="lab-footer"><span id="design-tradeoff"></span><button id="reset">恢复默认应用</button></footer>
|
||||
<div class="reference-note">设计参考:<a href="https://www.raycast.com/" target="_blank" rel="noreferrer">Raycast · 搜索与扩展</a><a href="https://www.notion.com/help/intro-to-workspaces" target="_blank" rel="noreferrer">Notion · 收藏与折叠分区</a><a href="https://code.visualstudio.com/docs/configure/profiles" target="_blank" rel="noreferrer">VS Code · Profiles</a><span>借鉴交互思路,保留 GoodBuddy 的视觉与入口分类。</span></div>
|
||||
<dialog id="catalog" aria-labelledby="catalog-title"><button id="back-to-apps" hidden>‹ 返回应用中心</button><header><div><span class="eyebrow">GOODBUDDY COLLECTION</span><h2 id="catalog-title">应用中心</h2></div><button id="close-catalog" aria-label="关闭">×</button></header><p class="muted" id="catalog-description"></p><div class="center-switch" role="group" aria-label="应用中心视图"><button data-catalog="apps">我的应用</button><button data-catalog="store">商店</button></div><input id="search" type="search" aria-label="搜索应用" placeholder="搜索应用…"><div id="catalog-items"></div><section id="app-settings" aria-label="魔法笔记设置" hidden></section><footer>常驻只表示菜单入口,不表示后台运行。安装与内容均为演示。</footer></dialog>
|
||||
<dialog id="catalog" aria-labelledby="catalog-title"><button id="back-to-apps" hidden>‹ 返回应用中心</button><header><div><span class="eyebrow">GOODBUDDY COLLECTION</span><h2 id="catalog-title">应用中心</h2></div><button id="close-catalog" aria-label="关闭">×</button></header><p class="muted" id="catalog-description"></p><div class="center-switch" role="group" aria-label="应用中心视图" hidden><button data-catalog="apps">我的应用</button><button data-catalog="store">未来私有市场</button></div><section id="market-preview" hidden>
|
||||
<label>私有市场 URL<input id="market-url" type="url" value="https://market.example.invalid" aria-describedby="market-status"></label><button id="market-fetch">模拟获取目录</button><button id="market-fail">模拟不可达</button><p id="market-status" role="status">未来预览,不发送网络请求;来源仅用于市场。</p>
|
||||
<details><summary>YAML 导入 / 导出预览(mock schema)</summary><p class="muted">仅支持下列扁平示例字段,非正式 Schema。支持 .yml / .yaml 的文本预览;不包含凭据或业务数据,不替代程序包或模型权重。</p><label>应用定义<textarea id="yaml-input" rows="9" spellcheck="false">schema: mock-v1
|
||||
id: reading
|
||||
name: 阅读空间
|
||||
version: 1.0
|
||||
entry: main
|
||||
dependencies: builtin:knowledge
|
||||
resources: bundled</textarea></label><button id="yaml-preview">预览导入</button><label>导出应用<select id="yaml-app"></select></label><button id="yaml-export">预览导出 YAML</button><pre id="yaml-output" tabindex="0" hidden></pre><div id="import-preview" hidden></div></details>
|
||||
</section><input id="search" type="search" aria-label="搜索应用" placeholder="搜索应用…"><div id="catalog-items"></div><section id="app-settings" aria-label="应用设置" hidden></section><footer>取消常驻仅隐藏捷径;关闭应用隐藏业务入口,保留数据与后台状态。此处全部为模拟。</footer></dialog>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,6 +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: 'projects', name: '项目管理', icon: 'layers', detail: '跟踪项目、任务与进度', summary: '把一个想法变成可跟进的项目', builtin: false },
|
||||
{ id: 'reading', name: '阅读空间', icon: 'book', detail: '集中阅读、批注与摘录', summary: '给长文和深度阅读留一个空间', builtin: false }
|
||||
];
|
||||
@@ -23,7 +24,7 @@ const variants = {
|
||||
cards: ['C / 应用预览', '入口展示内置应用图标,展开后用卡片说明每个应用的用途。', '适合通过内容理解应用,卡片不代表后台持续运行。'],
|
||||
launcher: ['D / 搜索启动台', '最底部就是搜索入口,也可以按 Ctrl / ⌘ K 打开。', '输入名称,用方向键与 Enter 打开已安装应用。'],
|
||||
favorites: ['E / 常驻管理', '从底部管理哪些应用常驻菜单,未常驻应用也可直接打开。', '常驻应用优先排列;入口显隐不影响安装和运行状态。'],
|
||||
scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。']
|
||||
scenes: ['F / 私有市场预览', '未来实验:私有市场 URL、模拟目录获取与 YAML 交换。', '不联网、不写入文件;mock schema 不代表正式协议。']
|
||||
};
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const demoProjects = [
|
||||
@@ -134,7 +135,20 @@ window.addEventListener('scroll', () => { if ($('#project-activity').matches(':p
|
||||
renderProjects();
|
||||
let variant = 'dock';
|
||||
let catalogMode = 'apps';
|
||||
let residentApps = ['notes', 'knowledge'];
|
||||
let residentApps = items.filter(item => item.builtin).map(item => item.id);
|
||||
let enabledApps = new Set(residentApps);
|
||||
let currentPage = '当前对话';
|
||||
let settingsApp = 'notes';
|
||||
let marketReady = false;
|
||||
let importDraft = null;
|
||||
const definitions = new Map();
|
||||
const modelServices = [
|
||||
{ id: 'ocr', name: 'OCR 文字识别', purpose: '文档入库 #12 · 正在识别第 8 / 24 页', engine: 'PaddleOCR worker', management: '受管进程', running: true, usage: '420 MB', users: '知识库 / 文档入库任务 #12' },
|
||||
{ id: 'speech', name: '语音识别', purpose: '语音输入 #18 · 正在转写', engine: '本机语音识别引擎', management: '受管进程', running: true, usage: '310 MB', users: '当前会话 / 语音输入 #18' },
|
||||
{ id: 'tts', name: '语音合成', purpose: '回复朗读 #19 · 正在合成', engine: '本机语音合成引擎', management: '进程内', running: true, usage: '180 MB', users: '当前会话 / 回复朗读 #19' },
|
||||
{ id: 'embedding', name: '向量生成', purpose: '文档向量化 #7 · 已处理 32 / 80 个片段', engine: 'BGE-small / ONNX Runtime', management: '进程内', running: true, usage: '128 MB', users: '知识库 / 文档向量化任务 #7' }
|
||||
];
|
||||
let modelConfirmation = null;
|
||||
let editingApps = false;
|
||||
let draggedApp = '';
|
||||
let installed = new Set(items.filter(item => item.builtin).map(item => item.id));
|
||||
@@ -143,23 +157,28 @@ let settingsOpen = false;
|
||||
const noteSettings = { showCount: true, mode: 'immediate', format: 'combined' };
|
||||
const noteModes = { immediate: '即时', 'after-save-auto': '保存后自动', 'after-save-manual': '保存后手动' };
|
||||
const noteFormats = { combined: '长评 + 要点', narrative: '长评', structured: '要点' };
|
||||
function renderNoteSettings() {
|
||||
$('#app-settings').innerHTML = `<div class="note-setting-row"><div><strong>常驻左侧菜单</strong><p>对应“显示魔法笔记入口”,移除后仍可从应用中心打开。</p></div><button class="demo-switch" role="switch" aria-label="常驻左侧菜单" aria-checked="${residentApps.includes('notes')}" data-note-toggle="resident"></button></div>
|
||||
function renderAppSettings() {
|
||||
const item = items.find(entry => entry.id === settingsApp);
|
||||
$('#app-settings').innerHTML = `<div class="note-setting-row"><div><strong>启用应用</strong><p>关闭后隐藏业务入口,保留常驻偏好、数据和后台状态。可在此重新开启。</p></div><button class="demo-switch" role="switch" aria-label="启用${item.name}" aria-checked="${enabledApps.has(item.id)}" data-app-toggle="enabled"></button></div>
|
||||
<div class="note-setting-row"><div><strong>常驻左侧菜单</strong><p>仅控制主导航捷径;应用关闭时记住此偏好。</p></div><button class="demo-switch" role="switch" aria-label="常驻左侧菜单" aria-checked="${residentApps.includes(item.id)}" data-app-toggle="pinned"></button></div>`;
|
||||
if (settingsApp === 'notes') $('#app-settings').innerHTML += `
|
||||
<div class="note-setting-row"><div><strong>显示未完成待办数量</strong><p>在左侧入口显示数量,超过 99 项显示 99+。本 Demo 使用 3 项示例。</p></div><button class="demo-switch" role="switch" aria-label="显示未完成待办数量" aria-checked="${noteSettings.showCount}" data-note-toggle="showCount"></button></div>
|
||||
<fieldset class="note-setting-options"><legend>AI 评论方式</legend><div class="note-segments" role="group" aria-label="AI 评论方式">${Object.entries(noteModes).map(([value, label]) => `<button data-note-mode="${value}" aria-pressed="${noteSettings.mode === value}">${label}</button>`).join('')}</div><p>即时:按回车并停止输入 5 秒后评论未保存草稿。保存后自动:保存后评论。保存后手动:点击 AI 分析后评论。</p></fieldset>
|
||||
<fieldset class="note-setting-options"><legend>AI 评论形式</legend><div class="note-segments" role="group" aria-label="AI 评论形式">${Object.entries(noteFormats).map(([value, label]) => `<button data-note-format="${value}" aria-pressed="${noteSettings.format === value}">${label}</button>`).join('')}</div><p>默认同时生成流式长评和结构化要点,也可只保留其中一种。</p></fieldset>
|
||||
<div class="note-settings-preview"><span class="eyebrow">当前配置预览</span><p>${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}</p><small>选择后即时保留于本次 Demo 会话,不调用 AI、不修改真实设置。</small></div>`;
|
||||
if ($('#note-settings-summary')) $('#note-settings-summary').textContent = `当前设置:${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}。待办数量${noteSettings.showCount ? '显示' : '隐藏'}。`;
|
||||
}
|
||||
function showNoteSettings() {
|
||||
function showAppSettings(id = 'notes') {
|
||||
settingsApp = id;
|
||||
const item = items.find(entry => entry.id === id);
|
||||
if (!$('#catalog').open) { catalogMode = 'apps'; $('#search').value = ''; }
|
||||
settingsOpen = true;
|
||||
$('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = '魔法笔记设置';
|
||||
$('#catalog-description').textContent = '调整入口与 AI 评论行为。应用中心和笔记页面共享这份演示配置。';
|
||||
$('#catalog-title').textContent = `${item.name}设置`;
|
||||
$('#catalog-description').textContent = '应用中心、应用页面和全局设置共用本次演示配置。';
|
||||
$('#back-to-apps').hidden = false; $('#app-settings').hidden = false;
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = true;
|
||||
renderNoteSettings();
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items', '#market-preview']) $(selector).hidden = true;
|
||||
renderAppSettings();
|
||||
if (!$('#catalog').open) $('#catalog').showModal();
|
||||
$('#back-to-apps').focus();
|
||||
}
|
||||
@@ -169,54 +188,75 @@ function notify(text) {
|
||||
}
|
||||
function renderApps() {
|
||||
$('#edit-apps').textContent = editingApps ? '完成' : '编辑';
|
||||
$('#resident-list').innerHTML = residentApps.map((id, index) => {
|
||||
$('#resident-list').innerHTML = residentApps.filter(id => enabledApps.has(id) && (variant === 'scenes' || items.find(item => item.id === id).builtin)).map(id => {
|
||||
const index = residentApps.indexOf(id);
|
||||
const item = items.find(entry => entry.id === id);
|
||||
return `<div class="resident-row" data-resident="${id}" draggable="${editingApps}"><button data-open="${id}" ${$('#page-label').textContent === item.name ? 'aria-current="page"' : ''}>${icon(item.icon)}<span>${item.name}</span>${id === 'notes' && noteSettings.showCount ? '<small class="note-count" aria-label="3 项未完成待办(示例)">3</small>' : ''}</button>${editingApps ? `<div class="edit-actions"><button data-move="${id}" data-direction="-1" aria-label="上移${item.name}" ${index === 0 ? 'disabled' : ''}>↑</button><button data-pin="${id}" aria-label="移除${item.name}">×</button><button data-move="${id}" data-direction="1" aria-label="下移${item.name}" ${index === residentApps.length - 1 ? 'disabled' : ''}>↓</button></div>` : ''}</div>`;
|
||||
}).join('') || '<p class="empty-apps">点击 +,选择常驻应用。</p>';
|
||||
const subtitles = { dock: '', grid: `${installed.size} 个已安装应用`, cards: '笔记 · 知识 · 自动化', launcher: 'Ctrl / ⌘ K', favorites: `${residentApps.length} 个应用常驻菜单`, scenes: '发现你的下一个好应用' };
|
||||
const subtitles = { dock: '', grid: '内置应用', cards: '笔记 · 知识 · 自动化 · 模型', launcher: 'Ctrl / ⌘ K', favorites: '管理常驻偏好', scenes: '未来私有市场 · 模拟' };
|
||||
$('#center-entry').innerHTML = `<button id="open-center" aria-haspopup="dialog"><span class="brand-mark">${variant === 'launcher' ? icon('search') : 'GB'}</span><span><strong>${variant === 'launcher' ? '搜索应用…' : '应用中心'}</strong>${subtitles[variant] ? `<small>${subtitles[variant]}</small>` : ''}</span><span class="entry-arrow">${variant === 'scenes' ? '↗' : '⌃'}</span></button>`;
|
||||
}
|
||||
function renderCatalog() {
|
||||
const query = $('#search').value.trim().toLowerCase();
|
||||
const matches = items.filter(item => (catalogMode === 'store' ? !item.builtin : installed.has(item.id)) && `${item.name}${item.detail}`.toLowerCase().includes(query));
|
||||
const matches = items.filter(item => (catalogMode === 'store' ? marketReady && !item.builtin : installed.has(item.id) && (variant === 'scenes' || item.builtin)) && `${item.name}${item.detail}`.toLowerCase().includes(query));
|
||||
if (variant === 'favorites') matches.sort((a, b) => Number(residentApps.includes(b.id)) - Number(residentApps.includes(a.id)));
|
||||
$('#catalog-items').innerHTML = matches.map(item => {
|
||||
const ready = installed.has(item.id); const pinned = residentApps.includes(item.id);
|
||||
return `<article class="center-app">${icon(item.icon)}<div class="center-app-info"><strong>${item.name}</strong><p>${item.builtin ? '内置应用' : '商店应用 · 示例'}</p><p class="app-detail">${variant === 'cards' ? item.summary : item.detail}</p></div><div class="center-actions">${ready ? `<button data-open="${item.id}">打开</button>${item.id === 'notes' ? '<button data-note-settings aria-label="魔法笔记设置">设置</button>' : ''}<button data-pin="${item.id}" aria-pressed="${pinned}">${pinned ? '取消常驻' : '常驻左侧'}</button>` : `<button data-install="${item.id}">模拟安装</button><button data-install-pin="${item.id}">安装并常驻</button>`}</div></article>`;
|
||||
}).join('') || '<p class="empty-apps">没有匹配的应用。可以换个关键词,或切换到商店。</p>';
|
||||
return `<article class="center-app">${icon(item.icon)}<div class="center-app-info"><strong>${item.name}</strong><p>${item.builtin ? '内置应用' : '私有市场 · 示例'}${item.id === 'models' ? ' · 计划预览' : ''}${ready ? enabledApps.has(item.id) ? ' · 已启用' : ' · 已关闭' : ''}</p><p class="app-detail">${variant === 'cards' ? item.summary : item.detail}</p></div><div class="center-actions">${ready ? `${enabledApps.has(item.id) ? `<button data-open="${item.id}">打开</button>` : ''}<button data-app-settings="${item.id}">${enabledApps.has(item.id) ? '设置' : '设置 / 重新开启'}</button><button data-pin="${item.id}" aria-pressed="${pinned}">${pinned ? '取消常驻' : '常驻左侧'}</button>` : `<button data-install="${item.id}">模拟安装</button><button data-install-pin="${item.id}">模拟安装并常驻</button>`}</div></article>`;
|
||||
}).join('') || `<p class="empty-apps">${catalogMode === 'store' && !marketReady ? '请先模拟获取私有市场目录。' : '没有匹配的应用,请换个关键词。'}</p>`;
|
||||
$('#yaml-app').innerHTML = items.filter(item => installed.has(item.id)).map(item => `<option value="${item.id}">${item.name}</option>`).join('');
|
||||
document.querySelectorAll('.center-switch button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.catalog === catalogMode)));
|
||||
}
|
||||
function openCatalog(mode = 'apps') {
|
||||
importDraft = null; $('#import-preview').hidden = true;
|
||||
if (variant !== 'scenes') mode = 'apps';
|
||||
settingsOpen = false; $('#back-to-apps').hidden = true; $('#app-settings').hidden = true;
|
||||
for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = false;
|
||||
$('.center-switch').hidden = variant !== 'scenes';
|
||||
$('#market-preview').hidden = mode !== 'store';
|
||||
catalogMode = mode; $('#catalog').dataset.variant = variant;
|
||||
$('#catalog-title').textContent = mode === 'store' ? '发现新应用' : '应用中心';
|
||||
$('#catalog-description').textContent = mode === 'store' ? '安装你需要的应用,自主决定是否常驻左侧菜单。' : '打开应用,或把常用应用留在左侧。系统工具请使用右侧工作栏。';
|
||||
$('#catalog-title').textContent = mode === 'store' ? '私有市场 · 未来预览' : '应用中心';
|
||||
$('#catalog-description').textContent = mode === 'store' ? '目录和安装均为模拟,未接入 ShareServer。导入不会启动模型或任务。' : '内置应用无需安装;启用与常驻分别设置。系统工具请使用右侧工作栏。';
|
||||
$('#search').value = ''; renderCatalog();
|
||||
if (!$('#catalog').open) $('#catalog').showModal();
|
||||
$('#search').focus();
|
||||
}
|
||||
function openPage(name) {
|
||||
if ($('#catalog').open) $('#catalog').close();
|
||||
function openPage(name, keepSettings = false) {
|
||||
currentPage = name;
|
||||
modelConfirmation = null;
|
||||
if ($('#catalog').open && !keepSettings) $('#catalog').close();
|
||||
$('#page-label').textContent = name;
|
||||
$('#page-content').replaceChildren();
|
||||
const title = document.createElement('h1'); title.textContent = name;
|
||||
const description = document.createElement('p'); description.className = 'muted'; description.textContent = '主内容区页面预览。右侧工作栏保持当前工具状态。';
|
||||
$('#page-content').append(title, description);
|
||||
$('#page-actions').innerHTML = name === '魔法笔记' ? `<button data-note-settings>${icon('settings')} 笔记设置</button>` : '';
|
||||
const app = items.find(item => item.name === name);
|
||||
$('.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>` : '';
|
||||
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 === '设置') { const button = document.createElement('button'); button.dataset.noteSettings = ''; button.textContent = '魔法笔记设置'; $('#page-content').append(button); }
|
||||
renderNoteSettings();
|
||||
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));
|
||||
renderApps(); notify(`已打开${name}`);
|
||||
renderApps(); if (!keepSettings) notify(`已打开${name}`);
|
||||
}
|
||||
function selectVariant(name) {
|
||||
variant = name; $('.app').dataset.variant = name;
|
||||
document.querySelectorAll('button[data-variant]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.variant === name)));
|
||||
[$('#variant-label').textContent, $('#variant-description').textContent, $('#design-tradeoff').textContent] = variants[name];
|
||||
history.replaceState(null, '', `#${name}`); renderApps();
|
||||
if ($('#catalog').open) openCatalog();
|
||||
const pageApp = items.find(item => item.name === currentPage);
|
||||
if (name !== 'scenes' && pageApp && !pageApp.builtin) openPage('当前对话');
|
||||
}
|
||||
function selectTool(id) {
|
||||
const content = {
|
||||
@@ -228,6 +268,76 @@ function selectTool(id) {
|
||||
$('#tool-content').innerHTML = content[id]; $('#tool-content').setAttribute('aria-labelledby', `tab-${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 => {
|
||||
const external = service.management === '外部管理';
|
||||
const active = service.management === '进程内' ? '已加载' : '运行中';
|
||||
const actions = external ? ['config'] : service.management === '进程内' ? [service.running ? 'release' : 'load'] : service.running ? ['stop', 'restart'] : ['start'];
|
||||
return `<article class="model-service"><strong>${service.name}</strong><p>${service.interrupted ? '任务已中断,需由原应用重新发起' : service.purpose}</p><p>执行服务:${service.running ? active : '未运行'} · 内存:${service.running ? service.usage : '已释放(模拟)'}</p><p>使用方:${service.interrupted ? '暂无活动使用方' : service.users}</p><div class="model-actions">${actions.map(action => `<button data-model="${service.id}" data-model-action="${action}">${modelActionLabels[action]}</button>`).join('')}</div>${modelConfirmation?.id === service.id ? `<div class="inline-confirm" role="group" aria-label="推理服务操作确认"><p>${modelActionLabels[modelConfirmation.action]} ${service.name} 将影响 ${service.interrupted ? '该执行服务,当前没有活动任务' : service.users}。仅模拟状态变化。</p><button data-model-cancel>取消</button><button data-model-confirm>确认${modelActionLabels[modelConfirmation.action]}</button></div>` : ''}<details><summary>引擎与错误详情</summary><p>${service.engine} · ${service.management}</p><p>最近错误:无(示例)。操作仅更新本页内存状态。</p></details></article>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
const modelActionLabels = { start: '启动', stop: '停止', restart: '重启', load: '加载', release: '释放', config: '连接配置' };
|
||||
function applyModelAction(id, action) {
|
||||
const service = modelServices.find(entry => entry.id === id);
|
||||
service.running = !['stop', 'release'].includes(action);
|
||||
if (['stop', 'release', 'restart'].includes(action)) service.interrupted = true;
|
||||
modelConfirmation = null;
|
||||
renderModels();
|
||||
$(`[data-model="${id}"]`)?.focus();
|
||||
notify(`已模拟${modelActionLabels[action]} ${service.name}`);
|
||||
}
|
||||
function previewImport() {
|
||||
importDraft = null;
|
||||
const preview = $('#import-preview'); preview.hidden = false; preview.replaceChildren();
|
||||
const values = {};
|
||||
const fields = ['schema', 'id', 'name', 'version', 'entry', 'dependencies', 'resources'];
|
||||
for (const line of $('#yaml-input').value.split(/\r?\n/).filter(line => line.trim() && !line.trim().startsWith('#'))) {
|
||||
const match = line.match(/^([a-z]+):\s*(.+)$/);
|
||||
if (!match || !fields.includes(match[1]) || Object.hasOwn(values, match[1])) { preview.textContent = '仅支持示例中的七个扁平字段,不接受重复或未知字段;请修正后重新预览。'; return; }
|
||||
values[match[1]] = match[2].trim();
|
||||
}
|
||||
if (fields.some(field => !values[field]) || values.schema !== 'mock-v1' || !/^[a-z][a-z0-9-]*$/.test(values.id) || values.entry !== 'main') { preview.textContent = '请填写完整 mock-v1 示例,id 使用小写字母、数字或连字符,entry 为 main。'; return; }
|
||||
const existing = items.find(item => item.id === values.id);
|
||||
const dependencies = values.dependencies === 'none' ? [] : values.dependencies.split(',').map(value => value.trim());
|
||||
const missing = dependencies.filter(value => !value.startsWith('builtin:') || !installed.has(value.slice(8)) || !enabledApps.has(value.slice(8)));
|
||||
const blocked = existing?.builtin || missing.length || values.resources !== 'bundled';
|
||||
const conflict = installed.has(values.id);
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `${values.name} · ${values.id} · v${values.version} · 主内容区入口。依赖:${values.dependencies}。${missing.length ? `缺失或关闭:${missing.join('、')}。` : '依赖已具备(模拟)。'}资源:${values.resources}。${values.resources !== 'bundled' ? '需补充外部资源,不能安装为可用。' : ''}${existing?.builtin ? '与内置应用冲突,不允许覆盖。' : conflict ? '同标识冲突:需显式更新定义,保留启用与常驻偏好。' : '没有同标识冲突。'}`;
|
||||
preview.append(text);
|
||||
const cancel = document.createElement('button'); cancel.textContent = '取消'; cancel.onclick = () => { importDraft = null; preview.hidden = true; $('#yaml-preview').focus(); }; preview.append(cancel);
|
||||
if (!blocked) {
|
||||
importDraft = values;
|
||||
const confirm = document.createElement('button'); confirm.id = 'import-confirm'; confirm.textContent = conflict ? '确认模拟更新定义' : '确认模拟安装';
|
||||
confirm.onclick = () => {
|
||||
if (!importDraft) return;
|
||||
const draft = importDraft;
|
||||
const item = items.find(entry => entry.id === draft.id);
|
||||
if (!item) items.push({ id: draft.id, name: draft.id, icon: 'layers', detail: 'YAML 导入 · 未来示例', summary: '导入的应用定义预览', builtin: false });
|
||||
// Imported text stays in textContent/textarea; catalog markup uses the validated identifier.
|
||||
definitions.set(draft.id, { ...draft });
|
||||
if (!installed.has(draft.id)) { installed.add(draft.id); enabledApps.add(draft.id); }
|
||||
importDraft = null; preview.hidden = true; renderCatalog(); renderApps(); $('#yaml-preview').focus(); notify('已模拟安装 / 更新定义,未启动模型或任务');
|
||||
};
|
||||
preview.append(confirm);
|
||||
}
|
||||
}
|
||||
$('#market-fetch').onclick = () => {
|
||||
const valid = /^https?:\/\/[^\s/]+(?:\/[^\s]*)?$/.test($('#market-url').value.trim());
|
||||
marketReady = valid;
|
||||
$('#market-status').textContent = valid ? `已模拟获取目录:${$('#market-url').value.trim()},无网络请求。` : '请输入 http(s) 市场 URL 后重试。';
|
||||
renderCatalog();
|
||||
};
|
||||
$('#market-fail').onclick = () => { marketReady = false; $('#market-status').textContent = '模拟来源不可达,请点击模拟获取目录重试。未切换公共市场,已安装应用仍可使用。'; renderCatalog(); };
|
||||
$('#market-url').oninput = () => { marketReady = false; $('#market-status').textContent = '来源已修改,请重新模拟获取目录。'; renderCatalog(); };
|
||||
$('#yaml-preview').onclick = previewImport;
|
||||
$('#yaml-input').oninput = () => { importDraft = null; $('#import-preview').hidden = true; };
|
||||
$('#yaml-export').onclick = () => {
|
||||
const item = items.find(entry => entry.id === $('#yaml-app').value);
|
||||
const definition = definitions.get(item.id) || { schema: 'mock-v1', id: item.id, name: item.name, version: '1.0', entry: 'main', dependencies: 'none', resources: 'bundled' };
|
||||
$('#yaml-output').hidden = false;
|
||||
$('#yaml-output').textContent = '# Mock schema only; no credentials or business data\n' + Object.entries(definition).map(([key, value]) => `${key}: ${value}`).join('\n');
|
||||
};
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('button'); if (!button) return;
|
||||
const data = button.dataset;
|
||||
@@ -246,13 +356,35 @@ document.addEventListener('click', event => {
|
||||
if (data.page) openPage(data.page);
|
||||
if (data.open) openPage(items.find(item => item.id === data.open).name);
|
||||
if (data.tool) selectTool(data.tool);
|
||||
if (button.hasAttribute('data-note-settings')) showNoteSettings();
|
||||
if (data.appSettings) showAppSettings(data.appSettings);
|
||||
if (data.appToggle) {
|
||||
const id = settingsApp;
|
||||
if (data.appToggle === 'enabled') enabledApps.has(id) ? enabledApps.delete(id) : enabledApps.add(id);
|
||||
else residentApps = residentApps.includes(id) ? residentApps.filter(entry => entry !== id) : [...residentApps, id];
|
||||
renderApps(); renderComposer();
|
||||
if (currentPage === items.find(item => item.id === id).name) {
|
||||
openPage(currentPage, true);
|
||||
}
|
||||
renderAppSettings(); $(`[data-app-toggle="${data.appToggle}"]`).focus();
|
||||
}
|
||||
if (data.modelAction) {
|
||||
if (data.modelAction === 'config') {
|
||||
const row = button.closest('.model-service');
|
||||
let config = row.querySelector('.model-config');
|
||||
if (!config) { config = document.createElement('p'); config.className = 'model-config'; config.textContent = '连接配置预览:Ollama · http://localhost:11434 · 外部管理。仅展示示例,不连接或保存配置。'; row.append(config); }
|
||||
} else {
|
||||
const service = modelServices.find(entry => entry.id === data.model);
|
||||
if (service.running && service.users) { modelConfirmation = { id: data.model, action: data.modelAction }; renderModels(); $('[data-model-cancel]').focus(); }
|
||||
else applyModelAction(data.model, data.modelAction);
|
||||
}
|
||||
}
|
||||
if (button.hasAttribute('data-model-cancel')) { const id = modelConfirmation.id; modelConfirmation = null; renderModels(); $(`[data-model="${id}"]`).focus(); }
|
||||
if (button.hasAttribute('data-model-confirm') && modelConfirmation) applyModelAction(modelConfirmation.id, modelConfirmation.action);
|
||||
if (data.noteToggle || data.noteMode || data.noteFormat) {
|
||||
if (data.noteToggle === 'resident') residentApps = residentApps.includes('notes') ? residentApps.filter(id => id !== 'notes') : [...residentApps, 'notes'];
|
||||
if (data.noteToggle === 'showCount') noteSettings.showCount = !noteSettings.showCount;
|
||||
if (data.noteMode) noteSettings.mode = data.noteMode;
|
||||
if (data.noteFormat) noteSettings.format = data.noteFormat;
|
||||
renderApps(); renderNoteSettings();
|
||||
renderApps(); renderAppSettings();
|
||||
const selector = data.noteToggle ? `[data-note-toggle="${data.noteToggle}"]` : data.noteMode ? `[data-note-mode="${data.noteMode}"]` : `[data-note-format="${data.noteFormat}"]`;
|
||||
$(selector).focus();
|
||||
}
|
||||
@@ -260,7 +392,7 @@ document.addEventListener('click', event => {
|
||||
if (button.id === 'edit-apps') { editingApps = !editingApps; renderApps(); $('#edit-apps').focus(); }
|
||||
if (data.pin || data.install || data.installPin) {
|
||||
const id = data.pin || data.install || data.installPin;
|
||||
if (data.install || data.installPin) installed.add(id);
|
||||
if (data.install || data.installPin) { importDraft = null; $('#import-preview').hidden = true; installed.add(id); enabledApps.add(id); notify('已模拟安装,未启动任务或模型'); }
|
||||
if (data.pin || data.installPin) residentApps = residentApps.includes(id) ? residentApps.filter(entry => entry !== id) : [...residentApps, id];
|
||||
renderApps();
|
||||
if ($('#catalog').open) { renderCatalog(); ($(`#catalog [data-pin="${id}"]`) || $('#search')).focus(); }
|
||||
@@ -282,7 +414,15 @@ $('#resident-list').addEventListener('drop', event => {
|
||||
$('#resident-list').addEventListener('dragend', () => { draggedApp = ''; });
|
||||
$('#search').addEventListener('input', renderCatalog);
|
||||
$('#close-catalog').onclick = () => $('#catalog').close();
|
||||
$('#back-to-apps').onclick = () => { const query = $('#search').value; openCatalog(catalogMode); $('#search').value = query; renderCatalog(); ($('#catalog [data-note-settings]') || $('#search')).focus(); };
|
||||
$('#back-to-apps').onclick = () => { const query = $('#search').value; openCatalog(catalogMode); $('#search').value = query; renderCatalog(); ($(`#catalog [data-app-settings="${settingsApp}"]`) || $('#search')).focus(); };
|
||||
$('#app-settings').addEventListener('keydown', event => {
|
||||
const group = event.target.closest('.note-segments');
|
||||
if (!group || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const buttons = [...group.querySelectorAll('button')];
|
||||
const index = buttons.indexOf(event.target);
|
||||
buttons[event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length].click();
|
||||
});
|
||||
function toggleWorkbar(visible) { $('.workbar').hidden = !visible; $('#toggle-workbar').setAttribute('aria-expanded', String(visible)); }
|
||||
$('#toggle-workbar').onclick = () => toggleWorkbar($('.workbar').hidden);
|
||||
$('#close-tool').onclick = () => { toggleWorkbar(false); $('#toggle-workbar').focus(); };
|
||||
@@ -300,7 +440,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 = ['notes', 'knowledge']; editingApps = false; renderApps(); notify('应用安装与常驻配置已恢复默认'); };
|
||||
$('#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('应用启用、常驻和笔记设置已恢复默认'); };
|
||||
let demoRunning = false;
|
||||
let demoRecording = false;
|
||||
let demoAttachments = [];
|
||||
@@ -317,8 +457,11 @@ function renderComposer() {
|
||||
const select = $(`#${id}`);
|
||||
if (select.value) addTag(select.selectedOptions[0].textContent, () => { select.value = ''; });
|
||||
}
|
||||
document.querySelectorAll('[data-knowledge]:checked').forEach(input => addTag(input.dataset.knowledge, () => { input.checked = false; }));
|
||||
if ($('#composer-retrieval').value === 'always') addTag('每次检索', () => { $('#composer-retrieval').value = 'auto'; });
|
||||
document.querySelectorAll('[data-knowledge-control]').forEach(element => { element.hidden = !enabledApps.has('knowledge'); });
|
||||
if (enabledApps.has('knowledge')) {
|
||||
document.querySelectorAll('[data-knowledge]:checked').forEach(input => addTag(input.dataset.knowledge, () => { input.checked = false; }));
|
||||
if ($('#composer-retrieval').value === 'always') addTag('每次检索', () => { $('#composer-retrieval').value = 'auto'; });
|
||||
}
|
||||
tags.hidden = !tags.childElementCount;
|
||||
$('#stop').hidden = !demoRunning;
|
||||
$('#send').disabled = !$('#message').value.trim() && $('#composer-command').value !== 'command';
|
||||
|
||||
@@ -4,20 +4,19 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 关联设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[助手工作栏 PRD](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
计划修订说明:当前实施范围以[第 8 节](#8-分期)为准。本文其余章节仍包含 v0.2 的
|
||||
左下角系统工具方案;启动实现前须同步功能要求、验收及配套设计,旧快捷工具条不再列入实施。
|
||||
本版已同步应用中心方案,分期见[第 8 节](#8-分期)。左下角用于访问和管理应用,系统工具保留在右侧工作栏。
|
||||
|
||||
主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
|
||||
底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端
|
||||
和浏览器则需要先找到右侧助手工作栏后才能打开。
|
||||
原主侧栏把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
|
||||
底部显示“GB 本地工作区”和平台架构。当前已将底部改为应用中心及独立设置入口,文件、终端
|
||||
和浏览器继续从右侧助手工作栏打开。
|
||||
|
||||
后续插件可能提供完整页面,也可能只提供辅助面板或命令。若不先区分系统入口、应用和工具,
|
||||
插件接入后会同时争用主导航、侧栏底部和助手工作栏,用户也无法判断入口会切换页面还是打开
|
||||
@@ -25,16 +24,17 @@
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 三类入口
|
||||
### 2.1 入口分类
|
||||
|
||||
| 类型 | 首期成员 | 默认位置 | 用户控制范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| 系统入口 | 当前对话、运行记录、设置 | 主导航固定位置或侧栏底部 | 不可隐藏、卸载或改变类型 |
|
||||
| 应用 | 魔法笔记、知识库、智能心跳 | 主导航应用区 | 可控制是否显示;隐藏不删除数据 |
|
||||
| 系统工具 | 文件浏览器、终端、浏览器、资源监控 | 侧栏底部快捷工具栏 | 首期固定提供,不可卸载 |
|
||||
| 固定内置应用 | 知识库、智能心跳 | 主导航固定位置及应用中心 | 始终启用并显示,不可启停、取消常驻或排序;保留工作区实际配置及心跳单条计划 enabled |
|
||||
| 可选内置应用 | 魔法笔记、本机推理 | 应用中心及主导航常驻区 | 独立控制启用、常驻与排序;保留数据 |
|
||||
| 系统工具 | 文件浏览器、终端、浏览器、资源监控 | 右侧助手工作栏 Tab 和“+”目录 | 自带且不可卸载,按工作栏规则管理实例 |
|
||||
|
||||
运行记录是系统审计页面,不属于工具。文件浏览器、终端、浏览器和资源监控是系统工具;它们
|
||||
的快捷入口位于左下角,实际界面继续由右侧助手工作栏承载。
|
||||
的入口与实际界面均由右侧助手工作栏承载,不列入应用中心。
|
||||
|
||||
### 2.2 后续插件类型
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
|
||||
## 3. 目标
|
||||
|
||||
- 用户从侧栏底部一次点击即可打开文件、终端、浏览器或资源监控。
|
||||
- 主导航只承载完整页面,辅助工具不占用一级页面位置。
|
||||
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
|
||||
- 主导航承载应用入口,本机推理打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 内置应用可以从一个设置位置控制主导航可见性,隐藏后仍可恢复。
|
||||
- 可选内置应用可独立设置启用与常驻,支持排序;知识库和智能心跳始终显示,未常驻的已启用应用仍可从应用菜单打开。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
@@ -56,7 +56,7 @@
|
||||
- 不把文件树、终端或浏览器重新实现到左侧栏。
|
||||
- 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。
|
||||
- 不允许隐藏运行记录、当前对话或设置。
|
||||
- 不因隐藏应用而删除魔法笔记、知识库或智能心跳数据。
|
||||
- 不因隐藏可选应用而删除业务数据。
|
||||
- 不把“显示在侧边栏”解释为停止智能心跳计划或后台任务。
|
||||
- 不提供任意本机程序启动器。
|
||||
- 首期资源监控不采集远程主机、GPU、显存或网络吞吐,不提供后台告警和历史持久化。
|
||||
@@ -75,42 +75,43 @@
|
||||
|
||||
### FR-3 内置应用区
|
||||
|
||||
魔法笔记、知识库和智能心跳显示在当前对话与运行记录之间。应用入口打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。用户可以在设置中控制其主导航可见性。
|
||||
主导航在当前对话之后依次显示固定知识库、智能心跳,再按用户顺序显示已启用且常驻的可选应用,运行记录位于应用区之后。两个固定应用不参与排序。本机推理入口打开独立 Modal,其余应用打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库和智能心跳只提供“打开”。
|
||||
|
||||
### FR-4 隐藏应用
|
||||
|
||||
隐藏应用只移除主导航入口,不删除数据、不修改业务启停状态,也不终止正在进行的操作。显隐
|
||||
Switch 更新成功后主导航立即生效;更新失败时恢复原状态并允许重试。用户可从设置恢复入口。
|
||||
取消常驻只移除主导航捷径,不删除数据、不修改启用状态,也不终止正在进行的操作。
|
||||
关闭可选应用则同步移除业务入口,保留常驻偏好;知识库和智能心跳不受这些偏好影响。
|
||||
配置保存成功后各入口使用同一已确认状态,失败时保持原状态并允许重试。
|
||||
|
||||
### FR-5 系统工具快捷栏
|
||||
### FR-5 应用中心入口
|
||||
|
||||
侧栏底部提供文件浏览器、终端、浏览器和资源监控四个图标按钮,并保留独立设置按钮。每个
|
||||
按钮具有可见工具提示和可访问名称。首期系统工具顺序固定为文件、终端、浏览器、资源监控。
|
||||
侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理打开 Modal,其余应用在主内容区打开页面。
|
||||
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
|
||||
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
|
||||
|
||||
### FR-6 文件浏览器入口
|
||||
|
||||
点击文件按钮时展开助手工作栏,并聚焦默认存在且不可关闭的“工作区”单实例。该实例保持
|
||||
在右侧选择“工作区”Tab 时聚焦默认存在且不可关闭的单实例。该实例保持
|
||||
“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随
|
||||
当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复
|
||||
入口;已有有效固定目标不受当前项目和默认工作区状态影响。
|
||||
入口;已有有效固定目标不受当前项目和默认工作区状态影响。固定目标失效时修复该目标,不静默回退到其他目录。
|
||||
|
||||
### FR-7 终端入口
|
||||
|
||||
点击终端按钮时展开助手工作栏。当前执行空间有正在启动或运行中的终端时聚焦最近使用的实例;
|
||||
没有时创建一个绑定当前执行空间的新终端,已退出、已中断或失败的历史 Tab 保持不变。没有
|
||||
项目时当前执行空间为本机 Home。创建额外并行终端继续使用工作栏现有的“+”目录入口。
|
||||
选择已有终端 Tab 仅激活该实例,保持目标、会话和历史状态。通过工作栏“+”选择终端时
|
||||
创建绑定当前执行空间的新实例,不按目标复用已有实例;无项目时使用本机 Home。
|
||||
远程项目的新实例绑定其托管 SSH 执行空间和权威目录。创建失败保留当前主页面与其他实例。
|
||||
|
||||
### FR-8 浏览器入口
|
||||
|
||||
点击浏览器按钮时展开助手工作栏,并优先聚焦绑定当前 Conversation 的最近使用浏览器实例;
|
||||
没有匹配实例时创建一个。没有活动 Conversation 时聚焦最近使用的浏览器实例;完全不存在时
|
||||
创建待绑定空实例。该快捷入口不会因连续点击创建重复实例;用户通过工作栏“+”选择浏览器时
|
||||
则始终创建新实例。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||
选择已有浏览器 Tab 保留其绑定目标、页面和导航状态;通过工作栏“+”选择浏览器时创建新实例,
|
||||
有活动 Conversation 时绑定该会话,否则创建待绑定空实例。同一 Conversation 的实例共享隔离
|
||||
登录上下文,页面与导航状态独立。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||
|
||||
### FR-9 资源监控入口
|
||||
|
||||
点击资源监控按钮时展开助手工作栏,并打开或聚焦单实例的本机“资源监控”面板。面板显示
|
||||
通过右侧工作栏“+”打开或聚焦单实例的本机“资源监控”,已有面板直接选择其 Tab。面板显示
|
||||
整机 CPU 使用率、内存使用量与总量、磁盘读写速率,以及 GoodBuddy 桌面进程的 CPU、内存
|
||||
和进程数摘要。指标不可用时显示具体项目和原因,不能显示为 `0`。
|
||||
|
||||
@@ -119,25 +120,28 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
|
||||
也不据此产生侧栏告警。首次采样及停止后恢复的首次采样只建立 CPU 和磁盘 IO 速率基线,
|
||||
不显示伪造的 `0` 值。
|
||||
|
||||
### FR-10 快捷入口状态
|
||||
### FR-10 工作栏入口状态
|
||||
|
||||
工具面板已激活时,对应按钮显示选中状态;面板存在但未激活时保持普通状态。运行中、错误或
|
||||
工具面板已激活时,对应 Tab 显示选中状态;面板存在但未激活时保持普通状态。运行中、错误或
|
||||
等待用户处理可以显示徽标,但必须有可访问文字,且不得自动抢占当前面板。
|
||||
|
||||
### FR-11 应用导航设置
|
||||
|
||||
设置页提供“应用与工具”管理入口。首期“应用”区域列出三个内置应用并提供“显示在侧边栏”
|
||||
Switch,切换后立即持久化。系统工具区域列出四项内置固定能力,不提供虚假的卸载或停用按钮。
|
||||
管理 Modal 为魔法笔记和本机推理提供应用设置、常驻管理和排序。知识库和智能心跳只提供“打开”,不添加通用空设置页;原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
|
||||
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
|
||||
魔法笔记设置迁入共同设置界面,应用中心与笔记页访问同一份配置,全局设置不再提供魔法笔记入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
|
||||
|
||||
### FR-12 偏好持久化
|
||||
|
||||
应用可见性按设备持久化。升级时保留当前行为:已有可用内置应用继续显示;魔法笔记原有功能
|
||||
启停设置保持原意,不由新的导航可见性覆盖。
|
||||
可选应用启用、常驻和顺序按设备持久化,重启后恢复。未配置的启用与常驻默认开启;
|
||||
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已明确保存的关闭和排序选择,
|
||||
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
|
||||
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。偏好仅包含 `magic-notes` 和 `local-inference`;未发布草案中的 `knowledgeEnabled`、`heartbeatEnabled` 已删除,不增加迁移或兼容读取。
|
||||
|
||||
### FR-13 窄窗口行为
|
||||
|
||||
窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时
|
||||
收起文字和非必要分隔,不隐藏四个系统工具或设置。
|
||||
窄窗口下主侧栏仍按现有抽屉方式显示,应用中心和设置保持可操作且不重叠。应用列表可滚动,
|
||||
搜索、设置及键盘排序不依赖拖拽;工具沿用右侧工作栏响应式规则,不搬到左侧。
|
||||
|
||||
### FR-14 项目活动汇总
|
||||
|
||||
@@ -169,49 +173,46 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
|
||||
- 连续点击系统工具不会无意创建重复的浏览器或终端实例;工作区始终复用不可关闭的固定实例。
|
||||
用户通过工作栏“+”可以显式创建额外浏览器或终端实例。
|
||||
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。知识库和智能心跳固定可用,只有两个可选应用可管理启用、常驻与顺序,首期无商店入口。
|
||||
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
|
||||
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
|
||||
最小化后停止请求新样本。
|
||||
- CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。
|
||||
- 隐藏内置应用不删除数据、不停止后台计划,并能从设置恢复;保存失败时入口保持原状。
|
||||
- 取消常驻不删除数据、不停止后台计划;关闭应用按统一规则联动业务入口,保存失败时各处保持原状。
|
||||
- 魔法笔记设置迁移保留已有选择,应用中心与笔记页共用同一配置。
|
||||
- 键盘、屏幕阅读器、浅色主题、深色主题和窄窗口均可完成相同操作。
|
||||
|
||||
## 8. 分期
|
||||
|
||||
### 首期
|
||||
|
||||
- 将侧栏最底部改为内置应用中心入口,设置按钮保持独立;具体列表、宫格或卡片样式从
|
||||
Demo 中收敛确定。
|
||||
- 应用中心提供魔法笔记、知识库、智能心跳的浏览、搜索与打开,不要求安装。
|
||||
- 支持应用常驻主导航、取消常驻和排序;未常驻应用仍可从应用中心打开,隐藏不删除数据或停止后台任务。
|
||||
- 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。
|
||||
- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理入口,不要求安装。本机推理 Modal 与服务单独实现和验证。
|
||||
- 支持魔法笔记、本机推理常驻主导航、取消常驻和排序;知识库和智能心跳固定显示,已启用未常驻应用仍可从菜单打开,隐藏不删除数据或停止后台任务。
|
||||
- 应用中心和应用页面就近提供已有功能设置,共享真实设置来源;已有全局设置入口定位到同一界面。
|
||||
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
|
||||
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
|
||||
- 原计划的本机资源监控仍作为系统工具交付项,在右侧增加单实例面板;与应用中心分别实现和验证。
|
||||
- 保持运行记录为固定系统入口。
|
||||
|
||||
首期验收需覆盖:无商店入口时可以访问全部内置应用;取消常驻后仍可重新打开和配置;
|
||||
首期验收需覆盖:无商店入口时可从菜单访问已启用应用,并从管理 Modal 恢复关闭的可选应用;取消常驻后仍可重新打开和配置;
|
||||
不同设置入口状态一致;常驻排序重启后保留;右侧工具切换不改变当前应用页面。
|
||||
本节是范围调整,Demo 交互通过不代表正式功能已经实现。
|
||||
|
||||
### 补充要求:魔法笔记迁移与入口联动
|
||||
|
||||
- 将现有魔法笔记设置迁入应用中心的应用设置界面,笔记页面内的设置入口访问同一份配置。
|
||||
魔法笔记可常驻主导航;原全局设置位置如保留入口,应定位到共同设置界面。
|
||||
魔法笔记可常驻主导航;移除原全局设置入口,保留已有配置值和业务数据。
|
||||
- 魔法笔记相关布尔开关默认全部开启,包括入口显示(常驻)和未完成待办数量显示。
|
||||
AI 评论方式、评论形式是单选项,沿用现有默认值,不把“全部开启”解释为同时选中多个模式。
|
||||
新默认值适用于未配置状态,迁移时保留用户已明确保存的选择。
|
||||
- 应用关闭后的入口联动必须覆盖主导航和会话页面,不能只更新应用中心卡片或一个菜单。
|
||||
知识库关闭后,左侧知识库菜单及会话中的知识库入口按钮同步移除;重新开启后按配置恢复。
|
||||
- “取消常驻”只移除主导航捷径;“关闭应用”移除该应用在主导航、会话等业务界面的入口。
|
||||
应用中心保留管理和重新开启入口。原型中仅有常驻控制,正式设计需补齐这两种状态,避免混用。
|
||||
- 入口变化不删除已有笔记或知识数据。知识库已打开页面、会话已关联知识以及后台任务的处理,
|
||||
在详细逻辑同步时分别定义,不能仅凭隐藏按钮推断功能或后台行为已经停用。
|
||||
- 知识库和智能心跳为固定内置应用,侧栏入口始终存在;会话知识范围、检索方式、标签及请求行为继续遵守知识库自身规则,心跳单条计划 enabled 仍由心跳页面管理。
|
||||
- 可选应用“取消常驻”只移除主导航捷径;“关闭应用”移除该应用在业务界面的入口。
|
||||
应用中心保留管理和重新开启入口,重新开启沿用常驻偏好,不自动导航。
|
||||
- 入口变化不删除已有数据。关闭后已打开的可选应用页显示不可用状态及恢复设置入口。
|
||||
已发出的请求和后台任务沿用各功能的生命周期规则,关闭入口不新增强制取消或重放行为。
|
||||
|
||||
补充验收:从任一配置入口关闭知识库,主导航和当前会话按钮立即同步移除,切换会话、
|
||||
重启后也不重新出现;重新开启后恢复相应入口。验证魔法笔记未配置时的默认开启,以及
|
||||
补充验收:关闭全部可选应用后,知识库和智能心跳侧栏入口仍可用,知识会话入口及心跳计划状态不变;管理 Modal 的两个固定应用行仅有“打开”,不能启停、取消常驻或排序。验证魔法笔记未配置时的默认开启,以及
|
||||
已有关闭选择在迁移后保留。设置保存失败时各入口保持原状态,不能出现互相矛盾的显示。
|
||||
|
||||
### 后续:应用商店与扩展
|
||||
@@ -229,12 +230,13 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定
|
||||
市场面向组织私有化部署。用户配置市场 URL 后即可获取该来源的应用目录与详情,并按需获取
|
||||
应用;需要认证的来源复用其登录流程。市场不可达时说明原因并允许重试,不影响已安装应用
|
||||
和内置应用使用,也不自动切换到公共市场。市场 URL 只影响应用市场,不改变模型、桌面更新
|
||||
或 Runtime 的下载源。
|
||||
或 Runtime 的下载源。市场 URL 按设备保存,重启后保留;认证失效时提示重新认证。
|
||||
|
||||
应用支持以 `.yml` 导入、导出,兼容 `.yaml` 扩展名,用于组织内部共享、备份和迁移。
|
||||
导出内容为应用定义及可迁移配置,包含格式版本、应用标识、应用版本、入口声明和依赖说明;
|
||||
凭据、会话、笔记及知识库业务数据不随应用定义导出。导入前展示应用摘要、缺失依赖和与已有
|
||||
应用的冲突;同标识应用由用户选择更新或取消,不静默覆盖本机配置。
|
||||
应用的冲突;同标识应用由用户选择更新或取消,不静默覆盖本机配置。成功后展示实际版本与可用状态;
|
||||
获取、校验或更新失败保留原已安装版本与配置,并显示可重试原因。
|
||||
|
||||
YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时应明确资源引用;离线导入仅在
|
||||
所需资源已具备时可完成使用,否则列出需要补充的资源,不显示为可运行。导入应用不会自动
|
||||
@@ -247,19 +249,18 @@ YAML 描述文件不能代替模型权重或插件程序包。有外部依赖时
|
||||
|
||||
### FR-16 本机模型管理入口(新增计划项)
|
||||
|
||||
应用中心提供“本机模型”内置管理应用,可选择常驻左侧,打开主内容区管理页。该入口统一
|
||||
查看各功能使用的本机模型及服务状态,包括已接入的 OCR、向量、语音或推理引擎;具体支持
|
||||
清单需核对现有模型服务后确定,不因提供入口而假定全部引擎均已支持进程控制。
|
||||
应用中心提供“本机推理”内置管理应用,可选择常驻左侧。从常驻入口、应用菜单或管理 Modal 的“打开”均进入同一独立管理 Modal;来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话及草稿保持不变。该入口统一
|
||||
查看本机语音识别、OCR、向量生成等服务的运行状态与可用资源指标。
|
||||
|
||||
页面展示模型名称、用途、所属引擎、管理方式、运行状态、占用情况及使用它的应用或任务。
|
||||
GoodBuddy 管理的进程提供启动、停止、重启和错误查看;外部启动的模型服务标明“外部管理”,
|
||||
提供连接状态与配置入口,未接入其管理接口时不提供停止或重启。进程内运行的模型按引擎实际
|
||||
能力提供加载与释放,不伪造独立进程。
|
||||
Modal 只有服务列表,显示名称、简短状态、引擎、模型、资源和错误,不显示任务历史、任务页签、单任务取消或外部连接列表。独立向量进程显示 CPU 与工作集内存,并标明进程范围;CPU 预热、采样失败和共享进程 worker 无法独立统计时说明原因,不能用零或整个应用进程占用代替服务指标。活动请求可能仍在排队或加载,不能据此宣称服务正在运行。ASR 当前仅显示按需运行且状态未知;OCR 和向量服务使用各自执行模块的状态。
|
||||
|
||||
停止或重启前展示正在使用该服务的任务和影响,由用户决定是否继续;取消时保留运行状态,
|
||||
失败时显示实际状态和原因。多个应用共用同一模型服务时展示同一个服务及其使用方,不重复
|
||||
启动进程。隐藏管理入口、关闭管理页面或取消应用常驻均不停止模型;卸载模型文件是独立操作。
|
||||
服务行保留实际支持的启动、停止、重启或模型加载、释放,工具栏保留刷新和模型设置入口。停止后重新启动不自动重放已中断任务。
|
||||
|
||||
资源监控仍是只读系统工具,本机模型管理负责模型生命周期,两者共享可用的状态来源。
|
||||
该项可独立于商店交付;当前只纳入计划,具体分期、任务使用期间的停止策略和各引擎管理能力
|
||||
在详细设计中确定。原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
停止、重启或释放前,在管理 Modal 上方打开紧凑影响确认,展示正在使用该服务的任务和影响;没有已登记活动任务时也说明执行前会再次核对。取消时保留运行状态,
|
||||
失败时关闭旧确认、显示原因并刷新状态,再次操作须重新确认当前影响。多个应用共用同一模型服务时只展示同一个服务,不重复
|
||||
启动进程。隐藏管理入口、关闭管理 Modal 或取消应用常驻均不停止模型;卸载模型文件是独立操作。
|
||||
确认打开时不能关闭底层管理 Modal;Escape 只关闭最上层,提交期间禁止关闭及重复执行。关闭后恢复可用入口焦点,具体规则见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
资源监控仍是只读系统工具,本机推理聚焦本地服务状态。该项独立于商店接入生产源码,支持 OCR 空闲加载与释放、内置向量进程启动/停止/重启;TTS 显示不可用。后端保留请求登记及原有取消能力,供执行路径和服务影响校验使用。各引擎边界见
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务),验证范围见[实施进度](./progress.md)。
|
||||
原首期资源监控的 GoodBuddy 桌面进程统计范围不因此扩大。
|
||||
|
||||
@@ -1,4 +1,223 @@
|
||||
# 项目活动级联实施进度
|
||||
# 应用导航实施进度
|
||||
|
||||
## 2026-09-18:独立推理进程资源采样
|
||||
|
||||
对应 FR-16、US-F3。向量 utility process 已按 PID 接入 Electron 真实 CPU 和工作集内存,服务行显示进程范围、PID、百分比及 MiB。ASR 为主进程线程,OCR 为渲染进程 Web Worker,分别显示不能独立统计的原因。采样口径及基线规则见[技术设计](./technical-design.md)。服务 Modal 继续保持单一列表。
|
||||
|
||||
本轮验证:资源采样器、Main 服务、向量 Broker、Modal 共 39 项定向测试通过;IPC 与 OCR Bridge 另有 130 项通过,共 169 项。类型检查、修改范围 ESLint、`git diff --check` 通过。全仓 lint 仅报告未修改的 story-graph Demo 的 22 个 `document` 错误;`npm test` 超过 60 秒未完成,不记为通过。中文功能文档 deai-writing 扫描阻断项为 0,复核项中的采样限制和验证边界按事实保留。
|
||||
|
||||
真实 Windows Electron 验证直接调用生产 `createEmbeddingUtilityTransport` 和采样器,使用临时 CPU 工作负载:PID 5892 的工作集从 57,769,984 字节变为 63,090,688 字节,区间 CPU 为 98.71%;退出后返回不可用,重启得到 PID 20532 并重新预热。进程刚 spawn 时指标可能尚未登记,返回等待数据。此验证没有加载推理模型,没有调用模型 API,也没有重新验收完整 App 窗口;macOS、Linux 尚未实机验证。改动仅涉及桌面本机服务监视,不影响 GoodBuddy Agent 或远程执行。
|
||||
|
||||
## 2026-09-18:本机推理服务监视简化
|
||||
|
||||
对应 FR-16、US-F3、US-F5;US-F4 的 Modal 单任务取消入口退役。`LocalInferencePage` 直接显示服务列表,移除任务页签、历史、外部连接和长说明,保留刷新、模型设置及受支持的服务操作。现行定义见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
当时没有服务级 CPU/内存采样,统一显示“暂不可用”;这一限制已由上方资源采样实现修正。ASR 模型可用时返回 `unknown`,登记活动请求不再将状态覆盖为 `running`。OCR/向量仍使用各自执行模块的状态。操作失败关闭旧确认并刷新,重试时重新确认当前影响;后端任务清单校验和 OCR 忙碌保护保留。
|
||||
|
||||
验证结果:
|
||||
|
||||
- 定向运行 LocalInferencePage、Main 服务、向量 Broker、OCR Bridge 和 IPC,5 个文件共 165 项通过;App 的本机推理入口和停用恢复场景 4 项通过。
|
||||
- `npm run typecheck` 和修改范围 ESLint 通过。`npm run lint` 仍报告无关 `docs/features/story-graph/demo.js` 的 22 个 `document` 错误。
|
||||
- `npm test` 首次超过 120 秒,再次超过 600 秒,均未完成。第二次报告的 App 失败来自旧页签断言,已改为服务列表并定向复验通过;不将全量测试记为通过。
|
||||
- 中文功能文档按 deai-writing 扫描,阻断项为 0;复核项中的状态限制和操作清单按产品事实保留。
|
||||
|
||||
本轮未启动真实 Electron 或调用模型,未重新验证原生窗口排版。改动仅影响桌面本机服务监视,不涉及 GoodBuddy Agent 或远程执行路径。未创建提交,下方历史记录保留当时的 UI 与验证边界。
|
||||
|
||||
## 2026-09-17:应用卡片与紧凑详情
|
||||
|
||||
对应 US-B6 至 US-B9、US-F3 至 US-F5。已核对 `ApplicationCenter`、`LocalInferencePage`
|
||||
及其样式:管理改为桌面双列、窄窗口单列卡片,固定应用仅有打开;可选应用提供打开和设置,
|
||||
关闭时禁用打开。启用、常驻及上下移动位于最大 `640px` 的详情,卡片拖动排序保留。
|
||||
详情使用紧凑返回标题栏,进入及返回时滚动归顶。本机推理使用内容高度 Modal,列表状态与操作
|
||||
分区并响应式排列。现行规则见 [UI 设计](./ui-design.md#4-应用中心与共同设置)。
|
||||
|
||||
本轮实现验证结果由实现方提供,本次仅更新文档,未重跑产品测试:
|
||||
|
||||
- 59 项组件测试及 8 项选定 App 测试通过;`npm run typecheck`、修改范围 ESLint 和
|
||||
`git diff --check` 通过。
|
||||
- 真实 Electron 使用当前 Renderer、已有 Main/Preload、真实 IPC 和隔离配置,四种尺寸、浅深
|
||||
主题共 32 个视图无横向溢出;导航、焦点、Escape、背景隔离及详情滚动重置已检查。
|
||||
- 实机任务列表为空态;有任务及长名称由组件测试覆盖。模型调用为 0,未重跑全量测试,
|
||||
未创建提交;此前全量测试与全仓 lint 的失败记录仍保留,不将整体验收标为完成。
|
||||
|
||||
实机报告已核对:`C:/Users/jiang/AppData/Local/Temp/opencode/application-cards-review-cUiVAc/result.json`,
|
||||
截图位于同目录。下方记录保留各轮当时的布局及验证范围,旧单列管理行和旧 Modal 尺寸不作为现行定义。
|
||||
|
||||
## 2026-09-17:本机推理改为独立 Modal
|
||||
|
||||
对应 FR-3、FR-5、FR-16、US-B1、US-B6、US-F3 至 US-F5。已核对当前 `App.tsx`、
|
||||
`LocalInferencePage.tsx`、`local-inference.css` 和 `dialog-focus.ts`:常驻入口、应用菜单及管理
|
||||
Modal 的“打开”均打开独立本机推理 Modal,保留底层工作区;其他内置应用仍打开主内容区页面。
|
||||
停止、重启和释放使用嵌套紧凑影响确认,复用背景隔离、焦点循环与恢复,按当前层处理 Escape,
|
||||
操作提交期间禁止关闭及重复执行。具体行为见 [UI 设计](./ui-design.md#91-本机推理管理)。
|
||||
|
||||
以下结果由实现代理提供,本次文档更新未重新运行代码测试:
|
||||
|
||||
- 180 项定向测试通过。
|
||||
- `npm run typecheck` 与修改范围的 ESLint 通过。
|
||||
- 完整 App 测试运行超时,未取得完整通过结果。
|
||||
- 全仓 lint 仍有 `docs/features/story-graph/demo.js` 的既有 22 个 `document` 全局声明错误。
|
||||
|
||||
随后完成真实 Electron 验证:当前 Renderer 源码配合当日已有 Main/Preload 构建,使用隔离配置,
|
||||
未模拟 IPC。三个入口均保留同一知识库工作区节点;背景隔离、Escape、焦点恢复及窄窗口侧栏
|
||||
开关回退通过。使用已有本机资产完成 OCR 模型加载与确认释放,推理请求和模型下载均为零。
|
||||
浅深主题及窄窗口未出现横向溢出,释放确认框约为 480 × 182 CSS 像素,内边距 16px、按钮高 36px。
|
||||
修正窄窗口关闭按钮换行后再次通过实机检查和 43 项相关测试。证据位于临时目录
|
||||
`inference-modal-ui-8KJFgQ/result.json` 及同目录截图;未验证重新打包后的应用。
|
||||
|
||||
`npm test` 在 240 秒边界停止,未取得全量通过结果;独立复跑心跳数据库测试为 6 项通过、
|
||||
2 项失败,均为已有的 `duplicate column name: pinned`。下方旧页面截图不作为 Modal 验收证据。
|
||||
本轮已同步源码与文档,未修改 Demo,未创建提交。
|
||||
|
||||
下方保留各轮当时的实现与验证记录,旧主内容区页面及无 props 描述不作为当前 Modal 的行为定义。
|
||||
|
||||
## 2026-09-17:生产界面密度与响应式复核
|
||||
|
||||
对应 US-B5 至 US-B9、US-F3。已审阅 `abcd-production-08rUQA` 中 A、B、D 的原生产截图。
|
||||
应用管理从全宽纵向块改为最大 `960px` 的内容高度 Modal:名称旁标明固定/可选状态,桌面操作
|
||||
同行,窄屏自然换行且排序箭头成组。共同设置统一标签、说明和开关对齐;本机推理改为弱分隔列表。
|
||||
菜单只调整共享字体和图标层级。展示规则见 [UI 设计](./ui-design.md#4-应用中心与共同设置)。
|
||||
|
||||
最终源码验证:
|
||||
|
||||
- 应用中心、应用菜单、本机推理、设置面板和共享 UI 的 5 个定向文件共 148 项通过。
|
||||
- `npm run typecheck`、改动 Renderer 文件的定向 ESLint、`git diff --check` 通过。
|
||||
- `npm run build:bundle` 成功;最后一处布局修正后,`npx electron-vite build --logLevel error` 再次成功。
|
||||
- 全仓 `npm run lint` 仍有 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误,未改动该文件。
|
||||
- 完整 `npm test` 用时 `648.80s`:4,454 项通过、67 项跳过、2 项失败。失败仍为心跳数据库
|
||||
迁移的 `duplicate column name: pinned`,位于未改动的 `assistant-database.ts:9988`;全量测试
|
||||
未通过。完整日志位于临时目录 `abcd-polish-tests-VxXZ34/test.log`。
|
||||
|
||||
真实 Electron 使用仓库 package 入口、当前生产 Main/Preload/App 和独立用户目录,未替换 IPC。
|
||||
最终截图与测量位于临时目录 `abcd-production-m9I4Vy`:`1200×775` 下四行约 `75px` 高,管理
|
||||
内容区 `scrollHeight` 与 `clientHeight` 均为 `420px`,操作同行;`520×740`、`375×667` 下
|
||||
管理、共同设置和推理内容无横向溢出。已审阅桌面与窄屏浅深色截图、375px 管理和设置截图。
|
||||
管理行按钮为 `34px`/`36px`,Switch 标签点击区高 `36px`,评论选项保留共享紧凑分段控件。
|
||||
生产验证覆盖菜单 End/Escape、Modal 焦点保持、页签方向键,以及 Space 切换常驻后重载 Renderer
|
||||
确认保存。运行报告为同目录 `result.json`,截图为 `A-menu*.png`、`B-manage*.png`、
|
||||
`C-settings*.png`、`D-inference*.png` 和 `D-tasks.png`。
|
||||
|
||||
此次为界面调整,未改后端生命周期;隔离目录没有安装模型,模型请求为 0,仅检查真实服务快照与任务空态。
|
||||
日志仍有既有字体 CSP 拦截,未捕获 JavaScript 异常。未验证模型启停、安装包或整个项目的功能验收。
|
||||
工作区原有 Runtime 与 story-graph 修改保留,未创建提交。
|
||||
|
||||
## 2026-09-17:轻量应用菜单与两个固定应用
|
||||
|
||||
对应 FR-3、FR-5、FR-11、FR-12、US-B5 至 US-B8、US-D1/US-D2。当前生产源码的底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选。点击应用行关闭菜单并打开主内容区;底部“管理应用”打开既有管理 Modal。
|
||||
|
||||
[ApplicationMenu](../../../src/renderer/src/ApplicationMenu.tsx) 与 [AnchoredMenu](../../../src/renderer/src/AnchoredMenu.tsx) 承担菜单及键盘、定位;[App](../../../src/renderer/src/App.tsx) 固定依次显示知识库、智能心跳,再显示已启用且常驻的可选应用。[管理 Modal](../../../src/renderer/src/ApplicationCenter.tsx) 中两个固定应用仅提供“打开”,不提供启用、常驻、排序或通用空设置;原工作区配置及心跳单条计划 enabled 不变。
|
||||
|
||||
[设置契约](../../../src/shared/application-settings-contracts.ts) 的可选偏好仅包含 `magic-notes`、`local-inference`。未发布草案中的 `knowledgeEnabled`、`heartbeatEnabled` 已删除,不增加迁移或兼容读取。[Store](../../../src/main/application-settings-store.ts) 持久化后发布权威快照的链路保留,包含配置工具写入;重开刷新期间锁定修改,较新快照使迟到的旧读取和保存响应失效。
|
||||
|
||||
本节收录本轮提供的最新验证结果;此次文档编辑未重新运行产品测试或模型请求:
|
||||
|
||||
- `npm run typecheck` 通过,`npx eslint src` 通过。
|
||||
- 完整 `npm test` 已结束:4,448 项通过、67 项跳过、2 项失败。两项仍为心跳数据库迁移的 `duplicate column name: pinned`,相关迁移未改动;全量测试未通过。
|
||||
- 全仓 `npm run lint` 仍有 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误,属于无关文档 Demo;全仓 lint 未通过。
|
||||
- Electron fixture 共 8 个主题/尺寸场景通过,覆盖原生键盘、焦点、菜单几何、原生视图遮挡及实际 `ApplicationSettingsStore` 持久化。此证据验证组件 fixture 和真实设置存储,不是完整生产 App 的端到端验收,也不是安装包验收。
|
||||
|
||||
完整生产 App 补充验证:`npm run build:bundle` 成功后,以隔离用户目录启动仓库 package 入口,
|
||||
使用实际 `out/main/index.js`、生产 Preload 和 App,未替换 IPC handlers。已验证上拉菜单、
|
||||
“管理应用”Modal、两个固定应用仅有“打开”、笔记常驻切换,以及刷新 Renderer 和磁盘配置的
|
||||
持久化结果;本机推理页通过真实注册 IPC 取得 ASR、OCR、向量和 TTS 状态。已审阅菜单与管理截图。
|
||||
证据位于临时目录 `abcd-production-08rUQA` 的 `result.json`、`A-menu.png`、`B-manage.png`、
|
||||
`D-inference.png` 和 `runtime.log`。生产启动日志有字体被 CSP 阻止的消息,未捕获 JavaScript 异常。
|
||||
此轮未重启整个应用验证持久化;隔离目录没有安装模型,未执行模型启停或模型请求。
|
||||
|
||||
A–D 已接入源码并通过上述生产 App 冒烟检查;不将全量测试、安装包及整个计划标为通过。
|
||||
D 的真实 ASR、OCR、向量引擎证据及最小 Main 接线限制保留在下方原记录中,本次状态读取不扩大
|
||||
其模型操作验证范围。本机资源监控仍待实现。Demo 的直接 Modal 和知识库/心跳可选设置已过时,本轮未编辑 Demo 文件。
|
||||
|
||||
下方为历史记录,保留当时实现、测试结果及限制,旧应用级开关和旧入口描述不作为现行行为定义。
|
||||
|
||||
## 2026-09-16:知识库固定入口与设置同步更正
|
||||
|
||||
本节更正下方早期记录的现行行为,历史实现和验证记录原文保留,不作为最新修订已重新通过验证的证据。
|
||||
A–D 的应用中心 Modal、可选应用共同设置、常驻排序及本机推理均已接入生产源码,整体验收边界仍见各次记录。
|
||||
|
||||
本次文档核对的源码证据:
|
||||
|
||||
- [共享设置契约](../../../src/shared/application-settings-contracts.ts) 将可编辑 ID 限为魔法笔记、智能心跳、本机推理;知识库不参与启用、常驻或排序。版本 12 为未发布分支格式,`knowledgeEnabled` 已移除,无兼容旧草案要求。
|
||||
- [应用中心](../../../src/renderer/src/ApplicationCenter.tsx) 的知识库行只有“打开”;[App](../../../src/renderer/src/App.tsx) 在首个可选槽位后插入固定知识库,再过滤可选应用。知识库始终显示,原工作区实际配置保留;旧的禁用知识库请求守卫说明已从现行设计中删除。
|
||||
- [设置 Store](../../../src/main/application-settings-store.ts) 持久化成功后发布 `onChanged`;[IPC](../../../src/main/ipc.ts) 转发完整快照,覆盖[配置工具服务](../../../src/main/goodbuddy-config-service.ts) 的写入。App 重开中心时刷新并锁定修改,较新的事件使在途旧读取和保存响应失效。
|
||||
- [向量 Broker](../../../src/main/knowledge/embedding-inference-broker.ts) 区分调用方返回与 Worker 执行结束;取消或超时后,执行记录在 Worker 确认或退出前仍占用在途名额并计入活动任务和服务停止影响。
|
||||
|
||||
本次仅更新文档并核对源码、链接和文本一致性,未运行产品测试或实机验证,也未发起模型请求。
|
||||
README 已标明 Demo 中知识库启停、常驻及通用设置描述待独立同步;本次未修改 Demo 文件。
|
||||
|
||||
## 2026-09-16:D 本机推理生产接入
|
||||
|
||||
对应 FR-16、US-F3 至 US-F5。`LocalInferencePage` 默认导出、无 props,已与中心代理的
|
||||
`local-inference` 页面入口对接,未修改 App 生产入口或应用启用契约。真实支持范围见
|
||||
[技术清单](./technical-design.md#111-本机推理任务与执行服务)。ASR、OCR、向量生产请求均登记
|
||||
生命周期,服务操作和任务取消独立。内置向量 Broker 按模型目录共享;OCR 直接控制持有模型的
|
||||
Renderer Worker。没有新增任务持久化、虚构进度、资源值或 PID。
|
||||
|
||||
验证证据:
|
||||
|
||||
- 原始 Vitest 配置运行 App、IPC、应用中心、设置存储、推理服务、ASR、OCR、向量 Broker、
|
||||
Worker 和引擎,共 12 个文件、452 项通过。完整 App 测试使用真实页面模块,没有 resolve 替身。
|
||||
- 收尾新增 OCR 加载失败恢复用例后,11 个定向文件 202 项通过;加上已通过的完整 App 251 项,
|
||||
当前覆盖合计 453 项。最终 `npm run typecheck`、D 源码及测试定向 ESLint、`git diff --check`
|
||||
均通过;diff 检查仅有工作区已有换行符提示。验证生成的临时 bundle 已清理。
|
||||
- Windows Electron 隔离用户目录,加载当前页面与生产 CSS、生产 Preload、实际引擎模块,
|
||||
通过临时最小 Main IPC 接线运行已安装的 SenseVoice、PaddleOCR、Granite 模型,未修改模型配置。
|
||||
最终一轮:3 次向量请求(含完成/取消竞态)、1 次 ASR 静音采样请求、1 次 OCR 图片请求。
|
||||
向量返回 384 维;UI 停止、启动后再次推理成功;隐藏窗口保持服务运行;OCR 识别 `HELLO`,
|
||||
UI 加载与释放成功;1080px 和 520px 窗口无横向溢出。未调用外部付费模型。
|
||||
- 实机取消竞态中执行方自然完成,管理页正确保留完成结果;取消确认和共享进程不被终止的分支
|
||||
另由 Broker 回归测试验证。停止失败、启动失败重试、新任务影响清单变化由定向测试覆盖。
|
||||
- 真实向量验证发现浏览器 ONNX `/wasm` 入口无法 fetch 本地资源;Main 改为 Node 条件入口后
|
||||
实机推理通过,具体执行错误可回传任务列表。
|
||||
|
||||
限制与阻塞:本轮不是打包安装包验收,也未驱动完整生产 App 的所有入口与原生浏览器视图。
|
||||
实机测试 Main 使用最小接线,完整生产 IPC 注册和 App 接入由上述原始测试及类型检查覆盖。
|
||||
全量 `npm test` 在 200 秒预算内未结束;已复现心跳迁移两项 `duplicate column name: pinned`,
|
||||
独立重跑仍失败,和应用设置 v12 期望值无关,未修改该数据库迁移。全仓 lint 仍被并行工作目录
|
||||
`docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误阻塞。不将全量测试或 lint
|
||||
标为通过。D 不修改 GoodBuddy Agent 或远程执行链路,无远程主机验证要求;没有创建提交。
|
||||
|
||||
本节取代下方早期记录中“缺少 LocalInferencePage”的当前阻塞描述,早期验证范围仍保留供追溯。
|
||||
|
||||
## 2026-09-16:应用中心、共同设置与知识联动
|
||||
|
||||
对应 US-A2、US-B5 至 US-B9。当前源码接入左下角应用中心和独立设置按钮;中心使用设置 Modal
|
||||
壳层,包含搜索、打开、常驻、拖动及上下移动。四个稳定 ID 为 `magic-notes`、`knowledge`、
|
||||
`heartbeat`、`local-inference`。应用启用、常驻和顺序独立持久化,固定入口及右侧工具不参与排序。
|
||||
|
||||
`ApplicationSettingsStore` 升为版本 12,保留已有 `false` 和评论单选值,为缺失项补默认值。
|
||||
笔记页与中心使用 `ApplicationSettingsView`;全局笔记入口已移除。保存期间共用锁,失败后读取
|
||||
确认值,无法确认时锁定修改并允许重新读取。关闭当前应用保留页面挂载及草稿,显示恢复入口。
|
||||
|
||||
知识关闭后移除会话知识控件与摘要,保留各会话关联;Main 交互请求入口在授予知识能力及
|
||||
检索前裁剪新请求范围,不取消已开始的请求。队列派发重新经过同一入口,本机和托管 SSH
|
||||
分支消费同一个请求对象。未修改 Skills、MCP、专家或本机推理页面与后端实现。
|
||||
|
||||
验证记录:
|
||||
|
||||
- 合并定向运行 App、IPC、配置与 Renderer 共 10 个 suite,599 项全部通过;随后新增本机推理
|
||||
主内容区入口用例,完整 App suite 251 项通过,合计覆盖 600 项;
|
||||
App 仍使用下述临时页面契约替身,其余生产代码未替换。
|
||||
- `ApplicationCenter.test.tsx`、设置、笔记、更新设置、共享界面与浏览器遮挡及配置契约的
|
||||
定向回归共 227 项通过;随后增加的保存失败、缺失笔记配置回归与配置契约共 39 项通过。
|
||||
- `ipc.test.ts` 和中心回归共 127 项通过,包含新请求禁用、在飞请求继续及重新开启的范围恢复。
|
||||
- `App.test.tsx` 共 251 项通过;因独立负责的 `LocalInferencePage.tsx` 尚未到位,使用临时
|
||||
Vitest resolve 插件提供无 props 空组件,仅用于验证 App 壳层,未替代或写入生产页面。
|
||||
- Windows Electron 使用当前中心组件、生产 CSS、真实 `ApplicationSettingsStore` 与临时
|
||||
最小 IPC 桥接验证常驻、排序、启用及重新读取持久化结果。浅深主题各验证 `1200 × 850` 和
|
||||
`680 × 560` 窗口,无水平溢出;搜索初始焦点、Escape、关闭焦点恢复及背景隔离通过。
|
||||
已审阅窄浅色和宽深色截图;证据位于本轮临时目录的 `application-center-*.png`。
|
||||
- 全量 `npm test` 的 JSON 报告为 4,153 项通过、4 项失败、67 项跳过,另有 suite 加载失败。
|
||||
其中应用配置契约旧快照已修正并定向通过;其余失败包括心跳旧库迁移两项、工作栏终端标题一项。
|
||||
终端标题用例随后单独复跑通过,未修改其实现。
|
||||
App suite 因缺少 `LocalInferencePage` 无法在原始配置下加载,不能将全量检查记为通过。
|
||||
- `npm run typecheck` 当前仅报告缺少 `./LocalInferencePage`;`npm run lint` 当前仅报告
|
||||
并行工作目录 `docs/features/story-graph/demo.js` 的 22 个 `document` 全局声明错误。
|
||||
本轮修改的生产源文件定向 Lint 通过。
|
||||
|
||||
剩余集成验证:接入另一实现负责的默认导出 `LocalInferencePage` 后重跑原始 App 测试与类型
|
||||
检查;完整应用的原生浏览器遮挡和共享 Linux Host 真实请求尚未实测。本轮真实模型调用为 0。
|
||||
本节不宣称本机推理后端、管理页或整个计划已交付,也没有生成提交。
|
||||
|
||||
## 2026-09-16:设置改为大 Modal
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||||
|
||||
## 1. 现状
|
||||
|
||||
主页面没有 URL Router,由 `App.tsx` 中的 `WorkspaceView` 和 keep-alive 状态切换:
|
||||
主页面没有 URL Router,由 `App.tsx` 中的 `WorkspaceView` 和 keep-alive 状态切换;该类型也包含由独立状态打开 Modal 的入口:
|
||||
|
||||
```ts
|
||||
type WorkspaceView =
|
||||
@@ -21,6 +21,7 @@ type WorkspaceView =
|
||||
| 'magic-notes'
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'local-inference'
|
||||
| 'activity'
|
||||
| 'settings'
|
||||
```
|
||||
@@ -28,90 +29,72 @@ type WorkspaceView =
|
||||
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
||||
承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程
|
||||
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
|
||||
面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。
|
||||
面板。侧栏底部已替换为应用中心与独立设置按钮。
|
||||
|
||||
本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/
|
||||
Renderer 最小实现。文件、终端和浏览器后端保持不变。
|
||||
应用中心、可选应用常驻及排序、启用联动、共同设置页和本机推理已接入生产源码;本机资源监控的
|
||||
Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。
|
||||
行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
| 模块 | 责任 |
|
||||
| --- | --- |
|
||||
| `App.tsx` | 组合主导航、系统工具栏、设置入口和页面离开检查 |
|
||||
| `SidebarSystemTools`(新增) | 渲染四个系统工具按钮并发送打开意图 |
|
||||
| `RightAssistantSidebar.tsx` | 接收打开意图,调用工作栏实例创建或激活逻辑 |
|
||||
| `App.tsx` | 组合主导航、应用中心、固定设置,持有确认配置及页面离开检查 |
|
||||
| `ApplicationMenu.tsx` / `AnchoredMenu.tsx` | 底部向上锚定的已启用应用菜单;关闭后导航,或打开既有管理 Modal;共享几何定位与键盘行为 |
|
||||
| `ApplicationCenter.tsx` | 浏览、搜索内置清单,调用既有页面导航及共同设置入口,编辑常驻和顺序;复用设置 Modal 样式和焦点工具 |
|
||||
| `ApplicationSettingsView` | 位于 `ApplicationCenter.tsx`,应用中心、应用页面、全局设置共用的应用配置表单 |
|
||||
| `RightAssistantSidebar.tsx` | 保留右侧工具 Tab 和“+”目录调用链路 |
|
||||
| `WorkbarShell.tsx` | 继续负责实例策略、Tab、目录和焦点行为 |
|
||||
| `workbar-contracts.ts` | 继续作为工作栏应用 ID 和实例布局的权威契约 |
|
||||
| `resource-monitor-contracts.ts`(新增) | 定义本机资源快照、可用性和错误码 |
|
||||
| `resource-monitor-service.ts`(新增) | 在 Main 中采集并规范化本机与 GoodBuddy 指标 |
|
||||
| `ResourceMonitorPanel.tsx`(新增) | 按需轮询、保留有界历史并渲染资源面板 |
|
||||
| `application-settings-contracts.ts` | 增加应用导航偏好 schema 和默认值 |
|
||||
| `application-settings-store.ts` | 迁移并原子保存应用导航偏好 |
|
||||
| `PlatformFeaturesSettingsSection` 或独立设置 Section | 编辑“显示在侧边栏”偏好 |
|
||||
| `application-settings-contracts.ts` | 定义常驻、顺序及应用启用字段;保留笔记既有配置键 |
|
||||
| `application-settings-store.ts` | 迁移默认值,串行合并更新并原子保存,返回完整确认快照 |
|
||||
| `PlatformFeaturesSettingsSection` | 移除全局笔记设置页签、跳转入口及专用回调,保留通用设置和远程项目 |
|
||||
|
||||
不要在 `SidebarSystemTools` 中读取文件、创建 PTY、操作浏览器或采集系统指标;该组件只发送
|
||||
有界的工具 ID。
|
||||
应用中心只编排应用导航和配置,不读取文件、创建 PTY、操作浏览器或采集系统指标。
|
||||
不增加左侧工具组件、左侧工具状态镜像或主侧栏到工作栏的专用命令通道。
|
||||
|
||||
## 3. Renderer 契约
|
||||
|
||||
### 3.1 系统工具 ID
|
||||
### 3.1 内置应用清单
|
||||
|
||||
```ts
|
||||
export const sidebarSystemToolIds = [
|
||||
'workspace',
|
||||
'terminal',
|
||||
'browser',
|
||||
'resources'
|
||||
] as const
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference'
|
||||
|
||||
export type SidebarSystemToolId = (typeof sidebarSystemToolIds)[number]
|
||||
```
|
||||
|
||||
该类型可以放在 Renderer 内共享模块。首期不暴露 IPC,因为动作只编排已有工作栏 Renderer
|
||||
状态;实际能力调用继续走现有 Preload API。
|
||||
|
||||
### 3.2 打开意图
|
||||
|
||||
`App` 向 `RightAssistantSidebar` 传入单调递增的请求或命令对象:
|
||||
|
||||
```ts
|
||||
type WorkbarOpenIntent = {
|
||||
requestId: number
|
||||
appId: 'workspace' | 'terminal' | 'browser' | 'resources'
|
||||
reason: 'sidebar-system-tool'
|
||||
context?: { conversationId?: string; projectId?: string }
|
||||
type BuiltInApplicationDefinition = {
|
||||
id: BuiltInApplicationId
|
||||
source: 'builtin'
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
view: BuiltInApplicationId
|
||||
}
|
||||
```
|
||||
|
||||
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
|
||||
关闭对应实例,且终端需要按执行空间寻找实例。
|
||||
全部应用 ID 在 `application-settings-contracts.ts` 的 `BuiltInApplicationId` 定义;`EditableApplicationId` 仅包含 `magic-notes`、`local-inference`。Renderer 的 `applicationDefinitions` 映射图标、国际化键和可选应用启用键,知识库和智能心跳始终可用。
|
||||
`App.tsx` 默认导入 `LocalInferencePage`,传入 `onClose`、`enabled` 和 `restoreFocus`。`local-inference` 保留为入口 ID,`setView` 在提交工作区切换前将其转为 `localInferenceOpen = true` 并返回,不更新 `view` 或 KeepAlive 缓存;组件按该独立状态挂载及卸载。
|
||||
静态清单复用既有应用页面和国际化键。管理 Modal 的搜索只过滤该清单,包含未常驻和关闭的应用,不请求
|
||||
远程目录。启用、常驻和排序从确认配置派生,不在清单中持久化第二份状态。
|
||||
|
||||
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。控制器必须
|
||||
接收上下文和调用原因,不能只按 `appId` 查找第一个实例:
|
||||
### 3.2 应用与设置导航
|
||||
|
||||
```ts
|
||||
type WorkbarController = {
|
||||
openOrActivate(intent: WorkbarOpenIntent): Promise<WorkbarTabInstance>
|
||||
}
|
||||
```
|
||||
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
|
||||
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
|
||||
`ApplicationMenu` 按知识库、智能心跳及可选顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。
|
||||
中心和应用页的设置操作仅接受 `EditableApplicationId`,全局设置旧入口定位同一个共同页。知识库和智能心跳的管理行仅提供“打开”,原工作区实际配置保留,不添加通用空设置页。
|
||||
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
|
||||
|
||||
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
|
||||
### 3.3 工作栏选择与创建
|
||||
|
||||
工作区意图始终激活注册表中的不可关闭单实例;仅在迁移或布局损坏时补回。浏览器意图按
|
||||
`conversationId` 激活最近使用的匹配实例,没有匹配实例时创建一个。工作栏“+”不走该去重
|
||||
路径,选择浏览器时始终创建新实例。
|
||||
已有 Tab 仅按实例 ID 激活,保留目标和历史状态。不得按当前项目、执行空间或 Conversation
|
||||
查找最近匹配实例替代用户选择,也不在选中时自动重建退出或失败终端。
|
||||
|
||||
### 3.3 终端匹配
|
||||
|
||||
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
|
||||
|
||||
1. 解析当前项目对应的 `ExecutionSpaceSelection`。
|
||||
2. 在现有终端实例中按目标 identity 查找 `starting` 或 `running` 状态。
|
||||
3. 存在多个匹配实例时激活最近使用的实例。
|
||||
4. 没有匹配实例时调用现有终端创建流程。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 实例保留为历史 Tab,不阻止快捷按钮创建新终端。
|
||||
工作栏“+”继续按原规则创建额外终端,不受快捷按钮去重影响。
|
||||
“+”选择终端每次调用既有创建流程,解析当前 `ExecutionSpaceSelection`,绑定本机或托管
|
||||
SSH 项目的权威目录;无项目时使用本机 Home。选择浏览器每次创建额外实例,绑定当前
|
||||
Conversation,无 Conversation 时创建待绑定空实例。同一 Conversation 的浏览器页面独立,
|
||||
登录上下文共享规则继续由工作栏负责。文件按单实例聚焦,缺失时仅由布局修复补回。
|
||||
选择现有文件 Tab 保留跟随或固定模式,固定目标失效时不切换目标。
|
||||
|
||||
### 3.4 资源监控工作栏应用
|
||||
|
||||
@@ -125,8 +108,8 @@ type WorkbarController = {
|
||||
}
|
||||
```
|
||||
|
||||
该应用不加入升级后的默认打开实例。侧栏资源按钮与工作栏“+”目录都通过既有单实例规则创建
|
||||
或聚焦它。
|
||||
该应用不加入升级后的默认打开实例,仅通过右侧已有 Tab 或“+”目录访问,使用既有单实例
|
||||
规则创建或聚焦。左侧应用清单不包含 `resources`。
|
||||
|
||||
## 4. 应用导航偏好
|
||||
|
||||
@@ -135,31 +118,34 @@ type WorkbarController = {
|
||||
在 `ApplicationSettings` 增加独立字段:
|
||||
|
||||
```ts
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
|
||||
type ApplicationNavigationSettings = {
|
||||
hiddenBuiltInApplicationIds: BuiltInApplicationId[]
|
||||
order: EditableApplicationId[]
|
||||
pinned: Record<EditableApplicationId, boolean>
|
||||
}
|
||||
```
|
||||
|
||||
默认值为空数组,保持升级前所有可用入口可见。ID 数组可以避免为每个应用增加一个顶层布尔
|
||||
字段;Zod schema 必须去重、限制数量并拒绝未知内置
|
||||
ID。
|
||||
|
||||
首期不保存排序,因为内置应用顺序固定。插件偏好在插件系统确定稳定 identity 和卸载保留
|
||||
规则后另行扩展,不提前混入当前 schema。
|
||||
`applicationNavigation` 仅保存可选应用顺序和常驻布尔值。默认顺序为
|
||||
`magic-notes`、`local-inference`,两项常驻默认 `true`。Zod 验证顺序恰好包含
|
||||
两个可选 ID 各一次,`pinned` 仅接受这两个键。知识库和智能心跳不进入导航 schema;Renderer 将它们固定置于可选应用之前,再按启用和常驻过滤可选项。取消常驻或关闭可选应用不删顺序。
|
||||
按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。
|
||||
首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。
|
||||
|
||||
### 4.2 与现有开关组合
|
||||
|
||||
入口可见条件为:
|
||||
可选应用侧栏入口可见条件如下,知识库和智能心跳固定显示:
|
||||
|
||||
```ts
|
||||
const visible = featureEnabled && !hiddenBuiltInApplicationIds.includes(id)
|
||||
const visible = featureEnabled && applicationNavigation.pinned[id]
|
||||
```
|
||||
|
||||
`magicNotesEnabled` 继续控制功能启停。知识库和智能心跳若未来增加业务启停开关,也按同一组合
|
||||
规则处理。功能停用时设置页保留显隐偏好但禁用显隐 Switch,并提供原功能设置入口。导航设置
|
||||
不能写回现有开关。
|
||||
`magicNotesEnabled` 保留为笔记启用的唯一权威键,`localInferenceEnabled` 为本机推理应用启用键,默认 `true`,不在导航配置再复制 enabled map。心跳没有应用级启用字段,单条计划 enabled 的业务含义和存储不变。关闭可选应用时保留常驻控件和恢复入口。
|
||||
|
||||
笔记配置继续使用 `magicNotesShowIncompleteTodoCount`、`magicNoteCommentMode` 和
|
||||
`magicNoteCommentFormat`,应用中心与笔记页使用同一 schema 和更新链路。未配置的笔记
|
||||
布尔值(含 `magicNotesEnabled`)默认 `true`;迁移必须用字段是否存在区分缺失与显式 `false`,
|
||||
不能用 `value || true`。已有单选值保留,缺失时分别使用 `immediate` 和 `combined`。
|
||||
若旧“显示入口”实际绑定 `magicNotesEnabled`,保留其启用含义和原值;独立常驻使用新字段,
|
||||
不得直接把旧业务启用键改名为 pinned。只为确认已发布的独立显隐偏好增加对应迁移。
|
||||
|
||||
### 4.3 持久化与 IPC
|
||||
|
||||
@@ -173,63 +159,69 @@ Renderer
|
||||
-> application-settings.json
|
||||
```
|
||||
|
||||
更新继续由 Main 校验可信 sender、Zod 输入和原子写入。设置版本迁移只补充默认空数组,不
|
||||
修改已有 `magicNotesEnabled` 或其他平台设置。
|
||||
Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore` 的更新队列,读取最新
|
||||
确认值后合并本次 patch、原子写入并返回完整 `ApplicationSettings`。写入失败不发布目标值。
|
||||
存储版本 12 补充可选应用导航和启用字段,仅为缺失笔记字段补新默认值;保留已有
|
||||
`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。
|
||||
版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled`、`heartbeatEnabled` 已移除;不为它们或旧三项/四项导航草案增加兼容读取器或迁移。
|
||||
|
||||
## 5. 侧栏底部重构
|
||||
|
||||
现有 `user-card` 是一个覆盖整行的设置按钮。实现时替换为容器和相邻独立按钮:
|
||||
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer">
|
||||
<SidebarSystemTools onOpenTool={openSystemTool} />
|
||||
<button onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
|
||||
{t('navigation.applicationCenter')}
|
||||
</button>
|
||||
<button aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
不能在设置按钮内部嵌套工具按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
应用中心和设置为相邻独立按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
|
||||
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
|
||||
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
|
||||
|
||||
系统工具按钮调用统一 `openSystemTool`:
|
||||
应用菜单使用 `AnchoredMenu` 的 body Portal,向上锚定,无模态遮罩或背景隔离;`menu` 语义纳入共享浏览器 viewport 相交遮挡。管理 Modal 及共同设置沿用原 Portal、焦点隔离、恢复及 Escape 机制。
|
||||
不在主侧栏复制工具入口。工作栏仍由窗口右上方固定开关展开和关闭,关闭后焦点返回该开关。
|
||||
|
||||
1. 设置工作栏打开意图。
|
||||
2. 展开工作栏。
|
||||
3. 由工作栏创建或激活实例。
|
||||
4. 按现有策略把焦点移入目标面板。
|
||||
## 6. 应用可用性与知识请求
|
||||
|
||||
工作栏仍由窗口右上方的固定开关关闭,关闭后的焦点返回该开关。
|
||||
Renderer 从同一确认快照派生可选应用主导航、中心状态和页面可用性,依赖只包含相应
|
||||
字段。keep-alive 页挂载不代表应用仍启用;禁用后在原位置渲染不可用状态和共同设置恢复入口,
|
||||
保留页面草稿及业务数据,阻止新的页面操作。重新启用不触发导航或业务重放。
|
||||
|
||||
## 6. 工作栏状态与快捷按钮
|
||||
`ApplicationAvailability` 隐藏并设置业务内容为 inert,保留挂载的页面实例;恢复入口由
|
||||
`ApplicationSettingsNavigation` Context 定位同一中心表单。笔记页面直接消费确认的评论配置,
|
||||
不因修改评论方式而重建编辑器。
|
||||
|
||||
`RightAssistantSidebar` 应向 `App` 暴露当前活动实例的 `appId` 和必要的有界状态摘要,或由
|
||||
现有 `onTabChange` 扩展得到。主侧栏不读取终端输出、文件内容、URL 或活动详情。
|
||||
知识库和智能心跳不经过 `ApplicationAvailability`;会话知识按钮、范围、检索方式和标签继续遵守原知识库规则,心跳计划 enabled 保持不变。
|
||||
请求构建没有应用级禁用知识库的裁剪或守卫;本机、托管 SSH 和排队请求沿用原知识上下文链路。
|
||||
单个知识库实例的配置、停用、删除及取消清理仍由知识库功能负责。
|
||||
|
||||
```ts
|
||||
type SidebarSystemToolState = {
|
||||
activeToolId?: SidebarSystemToolId
|
||||
statusByToolId?: Partial<Record<SidebarSystemToolId, 'attention' | 'error' | 'running'>>
|
||||
}
|
||||
```
|
||||
## 7. 串行保存与确认快照
|
||||
|
||||
首期可以只实现 `activeToolId`,没有可靠聚合来源时不显示状态徽标。不能根据局部事件猜测错误
|
||||
或运行状态。
|
||||
中心与应用页的启用、常驻、排序及笔记配置共用一个 Renderer 更新入口及 pending
|
||||
状态。任一提交未完成时,所有入口中修改这组设置的控件暂时禁用;下一次操作基于最新确认
|
||||
快照生成 patch,不提交整个过期 `ApplicationSettings` 覆盖无关字段。Main 队列仍负责串行合并。
|
||||
|
||||
## 7. 应用显隐更新
|
||||
|
||||
应用显隐 Switch 复用平台功能设置的即时更新模式。请求开始后禁用三个应用显隐 Switch,只
|
||||
允许一个完整目标集合处于提交中。Main 返回成功结果后更新 App 中的平台设置快照,主导航
|
||||
随之变化。失败时恢复控件原值,在对应设置行显示可重试错误;主导航继续读取最后一次成功
|
||||
保存的设置。
|
||||
`ApplicationSettingsStore.update` 在持久化成功后通过 `onChanged` 发布完整快照;IPC 转发
|
||||
`applicationSettingsChanged`,Preload 提供 `updates.onSettingsChanged`。工具配置服务调用同一 Store,
|
||||
因此配置工具写入也同步界面,保存失败不发事件。`App` 收到事件后推进读取修订号并替换快照,
|
||||
迟到的旧读取及旧保存响应不能覆盖新状态。每次重开中心都刷新配置,刷新期间锁定修改,失败后
|
||||
保留锁和重试入口;控件不先乐观生效。
|
||||
明确保存失败时保留最后确认值,就地显示可重试错误与编辑上下文。若 IPC 响应中断而写入
|
||||
结果未知,通过既有设置读取重新取得权威快照再解锁;读取失败继续显示错误并允许重试读取,
|
||||
不自动重发变更或写回旧配置。无须引入持久化回滚日志或独立导航快照文件。
|
||||
|
||||
## 8. 插件兼容边界
|
||||
|
||||
首期不增加插件注册表、插件 schema 或插件持久化字段,只保留系统 ID 不向插件开放的产品边界。
|
||||
插件市场设计需要单独确定 Host、权限、签名、生命周期和稳定 identity;不得直接复用 DeepSeek
|
||||
Harness 的 npm 插件市场。
|
||||
首期不增加市场 URL、YAML 导入导出、ShareServer API、插件注册表或安装服务。FR-15 的
|
||||
私有市场协议、YAML Schema、资源分发与扩展生命周期另行设计;系统 ID 不向插件开放。
|
||||
不得直接复用 DeepSeek Harness 的 npm 插件市场。后续依赖见第 11 节,首期不预建占位界面。
|
||||
|
||||
## 9. 本机资源监控
|
||||
|
||||
@@ -335,23 +327,28 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
|
||||
### 10.1 Renderer 测试
|
||||
|
||||
- 固定入口顺序不受三个应用显隐组合影响。
|
||||
- 运行记录和设置没有显隐控件。
|
||||
- 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
|
||||
- 文件复用不可关闭单实例;浏览器和终端按当前上下文聚焦匹配实例,工作栏“+”允许显式多实例。
|
||||
- 工具按钮展开工作栏但不改变当前主页面。
|
||||
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
|
||||
- 窄窗口下工具按钮不重叠,焦点恢复正确。
|
||||
- 固定入口不受应用启用、常驻和顺序组合影响,应用中心及设置始终可达;左侧没有重复系统工具。
|
||||
- US-B6:底部菜单向上锚定且无遮罩,只列已启用应用,不受常驻或打开历史影响;点击行关闭菜单并导航,“管理应用”进入既有可搜索 Modal,包含关闭及未常驻应用。
|
||||
- US-F3/US-F5:三个入口均打开本机推理 Modal 并保留底层工作区;覆盖嵌套确认的焦点循环、逐层 Escape、提交期间禁止关闭与重复执行、关闭后的入口及后备焦点恢复,以及停用内容后的键盘焦点。
|
||||
- US-B7:常驻、取消常驻、键盘及拖动排序成功后同步,重新开启及重启保留原偏好和顺序。
|
||||
- US-B8:知识库和智能心跳始终启用且位置稳定,管理行只有打开操作,心跳计划 enabled 不变;可选应用关闭后的不可用页保留草稿,重新开启不自动导航或重放。
|
||||
- US-B9:应用中心与笔记页定位同一笔记表单,启用、常驻、待办数量、评论方式及形式共用配置;全局设置无笔记入口。
|
||||
- US-B5:所有入口共享 pending,保存失败保留相同确认值;重开刷新期间锁定修改,较新事件使旧读取失效;结果未知时重读,不自动重发。
|
||||
- 文件复用不可关闭单实例;选择任一已有终端或浏览器 Tab 保留确切目标,“+”每次创建额外实例。
|
||||
- 已退出、失败的终端 Tab 不因选中而重建;工具切换不改变主页面,后台状态不抢占 Tab。
|
||||
- 窄窗口、键盘排序、共享 Switch、Tab 语义及浮层焦点恢复正确;中心和设置不被原生浏览器遮挡。
|
||||
- 资源面板只在可见活动时轮询,跳过重叠请求并把历史限制为 150 项。
|
||||
- 窗口隐藏、最小化和恢复时停止轮询并重新建立区间基线。
|
||||
- 资源部分可用、全部失败和重新进入活动状态均显示正确结果。
|
||||
|
||||
### 10.2 Main 与契约测试
|
||||
|
||||
- 新设置字段默认值、合法 ID、重复 ID、数量上限和未知 ID 校验。
|
||||
- 旧版本设置迁移后默认显示所有既有应用。
|
||||
- 更新导航偏好不修改魔法笔记启停等无关字段。
|
||||
- 原子写入失败时保留最后一次有效设置。
|
||||
- 顺序包含两个可选 ID 各一次;拒绝知识库、智能心跳、重复、缺失、未知 ID 及无效常驻字段;不为未发布的两个旧启用键添加迁移。
|
||||
- 未配置布尔项默认开启;已保存的 `false` 和评论单选值经迁移及重启保留。
|
||||
- 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。
|
||||
- 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。
|
||||
- Store 持久化后才发布变更,工具配置写入使用同一通知链路;知识库和智能心跳不受可选应用偏好影响。
|
||||
- 保存启用失败不会改变业务判断;会话关联和历史知识引用不因入口隐藏被清空。
|
||||
- 资源快照拒绝 NaN、Infinity、负数字节数、越界百分比和未知错误码。
|
||||
- 采集超时、权限不足和磁盘 IO 不支持时返回有界部分结果。
|
||||
- 超时采集尚未实际结束时保持单飞,不累积新的后台采集任务。
|
||||
@@ -371,22 +368,33 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
- 浅色、深色、`220px` 与 `420px` 侧栏宽度。
|
||||
- Windows、macOS 和 Linux 的真实 CPU、内存与磁盘 IO 采集;不支持项显示准确原因。
|
||||
|
||||
FR-16 单独覆盖 US-F3 概览去重、未知指标、US-F4 单任务取消与完成竞态、US-F5 服务
|
||||
实际启动/停止/重启或加载/释放、使用方确认、外部管理限制及失败真实状态;用接入的真实引擎
|
||||
验证共享服务不被单任务取消终止,重启不重放。设计文档检查或模拟服务不算生产路径验证。
|
||||
|
||||
## 11. 实施顺序
|
||||
|
||||
实施范围以 [PRD 分期](./prd.md#8-分期)为准。本节替代旧实施顺序;前文关于四个左侧
|
||||
系统工具按钮、仅持久化隐藏 ID 的契约需在实现前同步,不能直接按旧规格开发。
|
||||
实施范围以 [PRD 分期](./prd.md#8-分期)及最新评审决定为准,逻辑映射见
|
||||
[需求对应](./logic-design.md#6-与需求的对应关系)。既有 FR/US 编号保持稳定;新增故事使用
|
||||
US-B6 应用中心、US-B7 顺序、US-B8 启用联动、US-B9 笔记迁移,以及 US-F3 概览、
|
||||
US-F4 取消、US-F5 服务生命周期。PRD、User Stories、配套设计与根 UI 规范已同步入口及持久化规则。
|
||||
|
||||
1. 同步 PRD 功能要求、User Stories、逻辑、UI 与技术契约,收敛首期应用中心样式。
|
||||
以下为实施阶段划分,应用中心及本机推理已接入源码,验证状态以进度记录为准。
|
||||
|
||||
1. 底部入口使用向上锚定的轻量菜单,通过“管理应用”进入既有整窗 Modal;核对已发布配置的迁移来源。
|
||||
2. 接入现有内置应用页面与最底部应用中心入口,保留独立设置和固定运行记录入口。
|
||||
3. 为常驻显隐和排序设计最小持久化契约,保留已发布的相关设置与用户数据,按保存结果更新导航。
|
||||
4. 接入应用设置的共同界面与既有设置更新路径,先验证魔法笔记多入口状态一致性。
|
||||
5. 验证右侧现有文件、终端、浏览器 Tab;移除计划中的左下角工具快捷条和专用于该快捷条的意图接口工作。
|
||||
3. 实现常驻、完整顺序及启用契约,迁移时保留明确旧值,通过串行保存与确认快照同步所有界面。
|
||||
4. 迁移魔法笔记共同设置,保留知识库、智能心跳固定入口及原工作区配置、单条计划 enabled,验证可选应用关闭和重新开启。
|
||||
5. 验证右侧现有文件、终端、浏览器 Tab 的确切目标与“+”额外实例行为,不实现左侧重复工具条。
|
||||
6. 单独实现资源快照契约、Main 采集服务、Preload API 与右侧 `resources` 单实例面板。
|
||||
7. 验证键盘、焦点、浅深主题、窄窗口、设置失败、重启持久化和跨平台行为。
|
||||
|
||||
首期使用内置应用清单,不依赖远程商店、插件注册或安装服务。商店目录、推荐、安装与
|
||||
更新机制在后续独立设计中确定;Demo 中的模拟商店不接入首期产品界面。
|
||||
|
||||
本轮 `local-inference` 已接入管理 Modal、类型化 IPC 与真实引擎管理,支持范围见 11.1;
|
||||
实机和自动化验证范围见[实施进度](./progress.md)。
|
||||
|
||||
后续设计输入见 [FR-15](./prd.md#fr-15-私有化市场与-yaml-交换后续) 与
|
||||
[FR-16](./prd.md#fr-16-本机模型管理入口新增计划项):
|
||||
|
||||
@@ -398,11 +406,50 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
市场来源配置不覆盖现有模型、应用更新或 Runtime 下载源设置。
|
||||
- ShareServer 的市场发布与分发实现由其独立仓库承接,本仓库先记录依赖,不将桌面需求
|
||||
记录标为服务端已实现。
|
||||
- 本机模型管理实施前盘点现有 OCR、向量、语音及推理服务的生命周期接口,优先复用其
|
||||
Main 服务。只为实际支持的操作提供 typed IPC;区分受管子进程、外部服务和进程内模型,
|
||||
不把直连模型 HTTP 请求等同于独立模型进程。
|
||||
- 模型管理的详细逻辑需确定共享服务的使用方识别、忙碌时停止与重启、退出应用时的释放
|
||||
行为,再接入管理页面并验证实际引擎。该工作单独排期,不依赖市场上线。
|
||||
|
||||
### 11.1 本机推理任务与执行服务
|
||||
|
||||
ASR、OCR 与内置向量的跨功能执行边界、任务控制缺口及有条件的优化方向,见[本机专用推理架构与任务控制评估](../../architecture/local-inference-task-control.md)。该评估区分源码现状、建议与待办,未将常驻化或后台宿主迁移列为已采用实现。
|
||||
|
||||
`LocalInferencePage` 默认导出,接收必需的 `onClose` 及可选的 `enabled`、`restoreFocus`,通过 `createPortal(..., document.body)` 渲染管理 Modal,复用 `PageHeader` 和共享
|
||||
`activateModalFocus`/`trapTabFocus`。`ImpactConfirmation` 使用独立 Portal 和紧凑确认样式;共享焦点工具隔离下层 Modal,并在关闭时恢复可用触发元素或指定后备焦点。`operation` ref 防止重复执行,`busy` 和 `control` 控制关闭与 Escape,交互细节见 [UI 设计](./ui-design.md#91-本机推理管理)。`local-inference-contracts.ts` 定义快照、操作与取消输入;Main 注册四个
|
||||
`local-inference:*` 通道,复用可信 sender 校验和 Zod 输入校验。Preload 只暴露读取、服务操作、
|
||||
任务取消及打开既有设置的方法,不接收 PID、命令或模型路径。
|
||||
|
||||
| 引擎 | 生产接入与可用操作 | 明确限制 |
|
||||
| --- | --- | --- |
|
||||
| ASR / sherpa-onnx | `SpeechTranscriptionService` 登记真实请求;执行路径保留独立 Worker 请求取消 | 每次请求加载、结束释放;未提供 Worker 就绪观测,模型可用时状态为 unknown,不从活动请求推断 running |
|
||||
| OCR / PaddleOCR | `DocumentOcrBroker` 登记文档请求;`document-ocr-bridge` 提供实际 Worker 状态、加载及空闲释放 | 有活动或排队请求时拒绝释放;不把终止共享 Worker 暴露为单任务取消 |
|
||||
| 内置向量 / Granite | 按模型目录复用引用计数 Broker;启动、停止、重启;取消消息等待 Worker 结果或取消确认 | 启动确认进程 spawn,模型在首个请求时加载;不把进程启动称为模型已加载 |
|
||||
| TTS | 显示当前没有执行服务 | 不提供虚假任务或控制 |
|
||||
| 外部向量连接 | 快照契约保留配置,Modal 不显示;通过既有模型设置管理 | 本页不主动探测,不推断本机归属,不控制外部进程 |
|
||||
|
||||
`LocalInferenceService` 在 Main 内存中登记真实 ASR、OCR、向量请求,保留活动请求和最近
|
||||
100 条终态记录。只保存能力来源、时间、模型(执行路径有提供时)、状态和错误,不保存音频、
|
||||
图片或向量输入文本。运行阶段包括排队、模型加载及执行,没有统一阶段进度;来源未提供项目或
|
||||
会话对象时不补造对象关联。Modal 仅使用活动请求生成影响确认,不展示任务历史或单任务取消。
|
||||
|
||||
向量执行端通过 `utilityProcess.fork` 创建独立进程。每个 transport 持有自己的资源采样器,按当前 `child.pid` 从 `app.getAppMetrics()` 匹配 Utility 条目。服务快照的 `resources.scope` 为 `service-process`,携带 PID、采样时间及可用指标;无进程或无法采集时为 `unavailable` 并给出原因。Broker 清除退出的 transport,重启使用新采样器,不复用旧进程指标。
|
||||
|
||||
Modal 可见且启用时每 5 秒读取一次快照,跳过重叠请求。CPU 使用累计 CPU 秒数之差除以单调时钟间隔,100% 表示一个逻辑核心,可超过 100%;首次读取、PID 或创建时间变化、采样间隔超过 15 秒、计数回退及采样失败后重新建立基线。15 秒阈值为定时器延迟和跳过重叠请求留出余量。工作集由 Electron 的 KiB 转为契约中的字节,界面显示 MiB。缺少 CPU 时间或内存读数时只标记该指标不可用,不补零。数值为整个向量服务进程的占用,包含运行时和并发使用方,不是单任务或模型权重大小。
|
||||
|
||||
ASR 使用主进程中的 `node:worker_threads`;OCR 使用渲染进程中的 Web Worker,两者均没有独立进程工作集,逐行标明无法独立统计 CPU/内存。没有将主进程或整个 Renderer 的读数归属给服务。显存未采集,本机资源监控工作栏应用仍未实现。
|
||||
|
||||
服务操作按 serviceId 串行,操作期间拒绝新增该服务请求;停止前校验所有当前活动 taskId
|
||||
都在用户确认清单内,新增使用方要求刷新后重新确认。向量停止等待实际 child exit;失败保留
|
||||
可观测状态和错误。Renderer 操作失败时关闭旧确认并重新读取快照,刷新期间或失败时禁用服务操作;再次操作须重新确认当前活动请求。重启不重放中断请求。任务取消与服务停止分开,完成竞态保留执行方结果。
|
||||
|
||||
向量 Broker 将调用方 Promise 与 Worker 执行记录分开。调用方取消或超时可先返回错误,
|
||||
执行记录在 Worker 确认、返回结果或进程退出前仍为活动项,保留在途名额并计入服务停止影响;
|
||||
停止等待超时也不能提前移除记录。请求取消与自然完成竞争时采用执行方结果。
|
||||
|
||||
OCR 模型可用性由 Main 校验,Worker 运行状态由实际持有它的 Renderer 模块提供。手动加载
|
||||
同样复用既有 Worker,加载超时恢复为可重试错误;空闲 60 秒自动释放。Modal 启用、文档可见且内容未被 hidden/inert 隔离时每 5 秒刷新,跳过未结束的请求;嵌套确认期间暂停新快照读取。
|
||||
关闭 Modal 时卸载组件并清理轮询,窗口隐藏、关闭管理应用或取消常驻不会触发服务停止。退出整个进程继续
|
||||
沿用原有应用清理路径。模型文件卸载仍在模型设置中独立进行。
|
||||
|
||||
向量 Main 引擎使用 `onnxruntime-web` 的 Node 条件入口,避免浏览器 `/wasm` 入口通过 fetch
|
||||
加载本地文件失败。执行方的具体错误随任务返回;Renderer 不把失败展示为零指标或恢复成功。
|
||||
|
||||
## 12. 项目活动汇总
|
||||
|
||||
@@ -416,7 +463,7 @@ resourceMonitor.getLocalSnapshot(input: {
|
||||
顶层 Task 转为 `completed` 时加入集合,初始历史不生成通知。`App` 的本地 `done` 事件和
|
||||
活动运行持久化快照的成功终态调用 `markConversationCompleted`,失败、取消不调用。
|
||||
新运行及 Task 恢复运行或等待处理时清除旧通知。具体优先级和通知生命周期以活动逻辑为准。
|
||||
- `App` 仅在 `view === 'chat' && !settingsOpen` 时向 Hook 传入当前会话 ID;Hook 监听
|
||||
- `App` 仅在 `view === 'chat' && !settingsOpen && !applicationCenterOpen` 时向 Hook 传入当前会话 ID;Hook 监听
|
||||
`visibilitychange`,文档不为 `hidden` 时才按当前会话清除通知。这使普通侧栏、活动菜单
|
||||
导航和可见性恢复共用查看规则,KeepAlive 挂载本身不代表查看。集合不写入 SQLite 或设置。
|
||||
- 聚合状态新增 `completed`,全局和项目计数包含 `attention`、`running`、`completed`,
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 |
|
||||
| 版本 | 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 与工作栏布局遵守根规范。
|
||||
|
||||
## 1. 侧栏信息架构
|
||||
|
||||
@@ -24,110 +25,134 @@
|
||||
搜索
|
||||
|
||||
当前对话
|
||||
魔法笔记 3
|
||||
知识库
|
||||
智能心跳 1
|
||||
知识库(固定显示)
|
||||
智能心跳(固定显示,保留建议徽标)
|
||||
可选应用(魔法笔记、本机推理,按保存顺序,仅显示启用且常驻项)
|
||||
运行记录
|
||||
|
||||
最近对话
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[文件] [终端] [浏览器] [资源] [设置]
|
||||
[应用中心] [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口。三个内置应用位于二者之间。侧栏底部不再显示
|
||||
“本地工作区”和 `platform · arch`;排障信息移至关于或诊断页面。
|
||||
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
|
||||
知识库、智能心跳及本轮追加的本机推理,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
|
||||
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
|
||||
|
||||
## 2. 底部系统工具栏
|
||||
## 2. 底部应用中心入口
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,不做成账户卡片或嵌套卡片。按钮顺序固定:
|
||||
底部使用连续平整表面和顶部分隔线,应用中心在前,固定设置靠右,二者为独立按钮:
|
||||
|
||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `FolderOpen` | 文件 | 聚焦不可关闭的工作区面板 |
|
||||
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
||||
| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 |
|
||||
| 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 |
|
||||
| 1 | `Grid2X2` | 应用中心 | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
|
||||
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal |
|
||||
|
||||
图标使用项目现有 Lucide 库。每个按钮采用统一的 `34px × 34px` 点击区域,内部图标建议
|
||||
`16px`。工具栏使用 `8px` 内边距和 `4px` 间隔;设置按钮通过自动空白靠右,不添加第二条
|
||||
视觉分隔线。
|
||||
图标使用项目现有 Lucide 库。应用中心保留文字,设置图标采用统一的 `34px × 34px` 点击区域。
|
||||
底部使用共享间距和焦点样式,设置通过弹性空白靠右;不得把整个容器做成设置按钮。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
- 默认:透明背景,图标使用次级文字色。
|
||||
- 悬停:使用弱表面色,不能强于当前选中状态。
|
||||
- 选中:使用强调选中背景,并提高图标对比度。
|
||||
- 展开:应用中心按钮显示展开反馈,不能当作当前应用的选中状态。
|
||||
- 键盘焦点:显示统一焦点环,不能被侧栏边缘裁切。
|
||||
- 处理中:创建或激活请求未完成时暂时禁用对应按钮,保留尺寸并提供进行中名称。
|
||||
- 徽标:只用于等待处理、错误或活动状态,不显示无意义的常驻绿色圆点。
|
||||
- 加载:读取应用配置时保留入口,中心内显示加载状态;失败提供重试,不用默认值冒充已保存配置。
|
||||
|
||||
选中状态表达“对应工作栏面板正在显示”,不表达功能已安装或权限已授予。
|
||||
应用中心入口不承载工具运行徽标。全部可选应用关闭时菜单仍显示知识库、智能心跳及“管理应用”,可进入 Modal 重新开启可选应用。
|
||||
|
||||
## 3. 主导航应用区
|
||||
|
||||
应用入口沿用现有 `nav-item` 行,不使用应用卡片。图标、名称和徽标保持单行:
|
||||
|
||||
```text
|
||||
[图标] 魔法笔记 3
|
||||
[图标] 知识库
|
||||
[图标] 智能心跳 1
|
||||
[图标] 魔法笔记 3
|
||||
[图标] 本机推理
|
||||
```
|
||||
|
||||
隐藏某个应用后,其余应用向上补位。运行记录始终位于最后一个可见应用之后,不因应用显隐
|
||||
改变样式或归类为工具。
|
||||
取消常驻或关闭可选应用后不留下空白。知识库、智能心跳固定在当前对话之后,始终启用并显示,不受可选顺序影响。运行记录始终位于应用区之后。
|
||||
编辑常驻顺序提供拖动及“上移”“下移”按钮,首尾无可移动目标时禁用对应按钮;两种方式
|
||||
提交相同可选应用顺序。知识库、智能心跳及固定系统入口不参与拖动,也不作为排序目标。保存成功后更新,重启保留。
|
||||
|
||||
设置页打开期间,主侧栏显示最后一次成功保存的导航状态。Switch 更新成功后导航立即变化,
|
||||
失败时保持原导航。
|
||||
取消常驻不关闭当前页面或本机推理 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。
|
||||
主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。
|
||||
|
||||
## 4. 应用与工具设置
|
||||
## 4. 应用中心与共同设置
|
||||
|
||||
设置中心新增“应用与工具”分类,位于设置 Modal 的右侧内容区;容器与键盘规则遵守 [设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心):
|
||||
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单依次列知识库、智能心跳和按保存顺序排列的已启用可选应用;不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
|
||||
|
||||
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
|
||||
|
||||
管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
|
||||
|
||||
应用管理采用桌面双列卡片,窗口宽度不超过 `720px` 时改为单列。Modal 最大宽度 `960px`,
|
||||
高度随内容增长并受窗口限制。卡片显示图标、名称、状态和用途,底部放置操作;名称旁区分
|
||||
“固定入口”和“可选 · 已启用/已关闭”。长名称可换行,正文与控件均不可横向溢出。
|
||||
|
||||
```text
|
||||
应用与工具
|
||||
管理主导航中的应用。系统工具由 GoodBuddy 提供并固定在侧栏底部。
|
||||
|
||||
应用
|
||||
魔法笔记 笔记、待办和评论 [显示在侧边栏 开]
|
||||
知识库 管理和检索知识 [显示在侧边栏 开]
|
||||
智能心跳 查看计划、建议和运行历史 [显示在侧边栏 开]
|
||||
|
||||
系统工具
|
||||
文件 打开当前工作区 内置
|
||||
终端 进入当前执行空间 内置
|
||||
浏览器 打开 GoodBuddy 受控浏览器 内置
|
||||
资源监控 查看本机 CPU、内存和磁盘 IO 内置
|
||||
|
||||
系统入口
|
||||
当前对话、运行记录和设置位置固定,不参与上述配置。
|
||||
应用中心
|
||||
[搜索内置应用]
|
||||
知识库 固定入口 [打开]
|
||||
智能心跳 固定入口 [打开]
|
||||
魔法笔记 可选 · 已启用 [打开] [设置]
|
||||
本机推理 可选 · 已启用 [打开] [设置]
|
||||
```
|
||||
|
||||
应用显隐使用共享 Switch,标签必须是“显示在侧边栏”,不能缩写为“启用”。切换时立即保存,
|
||||
请求期间禁用三个应用显隐 Switch;失败后恢复原值,并在对应设置行显示可重试错误。系统工具
|
||||
使用紧凑只读列表,不渲染禁用的卸载、停用或固定按钮。
|
||||
已启用但未常驻的应用可以直接打开,打开不自动常驻。关闭的应用仍能被搜索和配置,
|
||||
保留禁用的“打开”按钮,可通过“设置”重新开启。启用、常驻及上下移动放在设置详情中;无搜索词且未锁定修改时,可选卡片仍支持拖动排序。无搜索结果显示清除搜索入口。
|
||||
首期不展示商店页签、推荐、安装按钮、市场 URL 或 YAML 操作,系统工具也不在此列出。
|
||||
|
||||
功能已停用时,对应应用行显示“功能已停用”和设置入口,保留显隐偏好但暂时禁用 Switch。
|
||||
### 4.1 共同应用设置页
|
||||
|
||||
若某应用自身支持业务启停,应在该应用原有设置中保留“启用功能”开关,并用说明区分:
|
||||
魔法笔记、本机推理的应用管理和应用页面入口定位到同一应用设置页,复用同一个表单及配置。知识库和智能心跳在管理 Modal 中仅提供“打开”,不提供启用、常驻、排序或通用空设置;原工作区实际配置入口保留。
|
||||
共同页遵守[设置中心 UI 规范](../../../UI-DESIGN.md#138-设置中心),不为每个入口建立一套设置。
|
||||
全局设置移除魔法笔记页签及跳转入口。旧“显示魔法笔记入口”绑定业务启用值,迁为“启用应用”并保留原值;
|
||||
“常驻左侧菜单”使用独立配置,只有已发布的独立显隐偏好才映射到常驻。
|
||||
|
||||
- “显示在侧边栏”只控制入口。
|
||||
- “启用功能”控制该功能是否运行。
|
||||
| 控件 | 表达含义 |
|
||||
| --- | --- |
|
||||
| 启用应用 | 控制业务可用性及主导航、会话等业务入口 |
|
||||
| 常驻左侧菜单 | 仅控制主导航捷径,关闭应用时保留该偏好,并说明开启后才显示 |
|
||||
| 显示未完成待办数量 | 仅控制魔法笔记导航徽标 |
|
||||
| AI 评论方式 | 即时/保存后自动/保存后手动,单选 |
|
||||
| AI 评论形式 | 长评 + 要点/长评/要点,单选 |
|
||||
|
||||
二元配置使用共享 Switch 和 `role="switch"`;评论选项使用共享单选控件。未配置布尔项
|
||||
默认开启,评论默认值沿用既有值,已有明确的关闭或单选选择保留。启用与常驻不可合并。
|
||||
设置详情最大宽度 `640px`,紧凑标题栏包含返回图标、应用名称、用途及关闭按钮。进入详情和
|
||||
返回卡片时正文滚动位置重置到顶部;进入详情聚焦关闭按钮,返回后聚焦搜索框。
|
||||
共同设置表单使用共享 Switch,相关字段分组,组间使用弱分隔线;说明沿用正文字号。
|
||||
评论分段控件按内容占宽,窄窗口中标签与选项上下排列;上下移动按钮位于详情表单下方。
|
||||
智能心跳单条计划 CRUD 及 enabled 仍由其业务页面负责,没有应用级心跳启用开关。
|
||||
|
||||
所有入口共享保存中状态。串行保存期间禁用相关启用、常驻、排序及笔记配置控件,保留
|
||||
最后确认值;持久化成功后的设置事件同步中心、侧栏、会话、应用页及其他设置入口,包括工具配置写入。重开中心刷新期间锁定修改,迟到的旧读取不能覆盖更新快照。失败在发起操作
|
||||
附近显示可重试错误并保留焦点;结果未知时显示“正在确认保存结果”,重读成功后再开放修改。
|
||||
|
||||
### 4.2 关闭后的页面与会话
|
||||
|
||||
关闭当前可选应用成功后,原页面或本机推理 Modal 显示“应用已关闭”和“前往应用设置”,保留草稿和内容状态,
|
||||
暂停该页面的新业务操作。重新开启后恢复可用性,不自动跳到已离开的应用页或重放任务。
|
||||
|
||||
知识库和智能心跳不显示应用关闭状态;会话知识控件、历史引用、工作区配置及心跳计划遵守原功能规则。可选应用的已有请求和后台任务继续按功能生命周期展示,详细边界见[关闭应用与恢复](./logic-design.md#32-关闭应用与恢复)。
|
||||
|
||||
## 5. 助手工作栏反馈
|
||||
|
||||
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
|
||||
系统工具仅从右侧工作栏已有 Tab 或“+”目录进入,工作栏按现有布局回流,主内容页面保持不变。
|
||||
|
||||
- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。
|
||||
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
|
||||
- 浏览器:激活绑定当前 Conversation 的最近使用“浏览器”Tab;没有匹配实例时创建一个,
|
||||
没有 Conversation 时按产品规则复用最近实例或进入待绑定空状态。
|
||||
- 终端:选择已有 Tab 激活该确切终端并保留目标与历史状态;“+”每次创建额外终端,按当前
|
||||
项目选择本机或托管 SSH,无项目时使用本机 Home。新建期间显示进度,失败保留诊断。
|
||||
- 浏览器:选择已有 Tab 保留其 Conversation、页面及导航状态;“+”每次创建额外浏览器,
|
||||
无活动 Conversation 时新建待绑定空实例。不会按当前会话改选其他 Tab 或重绑已有实例。
|
||||
- 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。
|
||||
|
||||
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
|
||||
属于显式操作,可以展开工作栏并切换 Tab。
|
||||
文件和资源监控在“+”中按单实例规则聚焦或创建,终端和浏览器不按目标去重。
|
||||
后台事件可以更新 Tab 徽标,不能自动展开工作栏或切换当前 Tab。
|
||||
|
||||
### 5.1 资源监控面板
|
||||
|
||||
@@ -167,7 +192,9 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 设置分类 | 应用与工具 |
|
||||
| 底部入口 | 应用中心 |
|
||||
| 菜单底部管理入口 | 管理应用 |
|
||||
| 应用设置开关 | 启用应用 |
|
||||
| 文件系统工具 | 文件 |
|
||||
| 终端系统工具 | 终端 |
|
||||
| 浏览器系统工具 | 浏览器 |
|
||||
@@ -175,7 +202,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
| 资源系统工具 | 资源监控 |
|
||||
| 资源范围 | 本机 |
|
||||
| 指标缺失 | 不可用 |
|
||||
| 应用显隐开关 | 显示在侧边栏 |
|
||||
| 应用常驻开关 | 常驻左侧菜单 |
|
||||
| 关闭的应用页 | 应用已关闭 |
|
||||
| 内置来源 | 内置 |
|
||||
|
||||
不再使用“本地工作区”作为侧栏底部身份名称。项目和默认工作区的路径继续由项目选择器、页面
|
||||
@@ -183,19 +211,22 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
## 7. 键盘和无障碍
|
||||
|
||||
- 底部按钮均为独立 `button`,不能嵌套在整行设置按钮中。
|
||||
- Tab 顺序为文件、终端、浏览器、资源监控、设置。
|
||||
- 底部应用中心和设置均为独立 `button`,Tab 顺序与视觉顺序一致,不嵌套按钮。
|
||||
- 图标按钮通过 `aria-label` 提供名称,工具提示不能作为唯一可访问名称。
|
||||
- 工作栏打开后按现有焦点策略聚焦目标面板;用户通过固定工作栏开关关闭时,焦点返回该开关。
|
||||
- 选中按钮使用 `aria-pressed` 或等价的可访问状态,不能只依靠背景色。
|
||||
- 徽标的数量或状态合并进按钮可访问名称,例如“终端,1 个等待处理”。
|
||||
- 应用显隐 Switch 支持 Space 切换,保存失败后恢复原值并保留焦点。
|
||||
- 应用中心按钮提供 `aria-haspopup="menu"`、`aria-expanded` 和控制关系;菜单使用 `menu`/`menuitem`,打开后聚焦首个可用项,上下方向键循环选择,Home、End 到首尾,Enter、Space 激活。
|
||||
- Escape 关闭菜单并恢复入口焦点;Tab 关闭后继续正常焦点顺序,点击外部、焦点离开或再次点击入口也关闭。菜单不隔离背景,不增加遮罩。
|
||||
- “管理应用”打开的 Modal 使用共享 Portal、整窗遮罩、原生浏览器遮挡、背景隔离和焦点循环,初始焦点进入搜索。卡片操作与详情控件按正常 Tab 顺序访问,不伪装成单个菜单项;详情切换焦点见共同应用设置页。
|
||||
- 工作栏使用 `tablist`、`tab`、`tabpanel`、`aria-selected`、游标焦点和方向键、Home、End。
|
||||
“+”使用普通按钮语义,同类型 Tab 仍按实例独立标记。
|
||||
- 徽标数量或状态合并进可访问名称;Switch 支持 Space,失败保留焦点。排序按钮名称包含
|
||||
应用名,操作结果通过 polite 状态区播报;拖动不是唯一排序方式。
|
||||
|
||||
## 8. 响应式
|
||||
|
||||
- 侧栏宽度 `220px` 至 `420px` 时,底部按钮尺寸不随宽度缩放。
|
||||
- 窄窗口继续使用现有侧栏抽屉;工具栏不转为多行。
|
||||
- 空间不足时缩小设置按钮前的弹性空白,不隐藏四个系统工具和设置。
|
||||
- 侧栏宽度 `220px` 至 `420px` 时,应用中心与设置均可操作,不随宽度缩小点击区域。
|
||||
- 窄窗口继续使用现有侧栏抽屉,底部入口不重叠;应用菜单向上展开并限制窗口边界、内部滚动。管理 Modal 的长名称可换行,搜索、设置与重新开启入口始终可达。
|
||||
- 右侧 Tab 单行横向滚动,“+”持续可见;工作栏保持停靠回流,不遮罩主页面。
|
||||
- 工具提示不得超出窗口;靠近底边时显示在按钮上方。
|
||||
- 粗指针设备不依赖悬停,按钮的可访问名称和选中反馈保持有效。
|
||||
|
||||
@@ -219,8 +250,31 @@ CPU 4% · 内存 620 MB · 桌面进程 7
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与四个系统工具的位置
|
||||
保持保留,不向插件开放。
|
||||
FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在后续设计中定义。
|
||||
首期中心只浏览本地内置清单,不显示未来市场的空壳界面。
|
||||
|
||||
### 9.1 本机推理管理
|
||||
|
||||
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;点击遮罩不关闭。关闭后优先恢复仍可用的原触发元素,入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关。
|
||||
服务采用弱分隔列表;名称使用区块标题字号,状态及引擎元数据使用
|
||||
辅助字号,状态与名称同组并与操作区分开,原因与操作说明使用正文字号。桌面操作独立位于右侧;
|
||||
窗口宽度不超过 `720px` 时移到正文下方并左对齐,保留完整状态和原因。
|
||||
|
||||
- US-F3 每行显示服务名、简短状态、引擎、模型及错误。ASR 可用时显示“按需运行(状态未知)”;OCR 和向量使用执行模块的状态。TTS 显示不可用,外部连接不列入本机服务。
|
||||
- US-F4 不再提供单任务取消入口;活动任务仅在服务操作的影响确认中出现。
|
||||
- US-F5 服务行右侧保留实际支持的启动、停止、重启或模型加载、释放。
|
||||
- 停止、重启、释放前打开最大宽度 `480px` 的紧凑嵌套确认,列出正在使用的任务与影响;无已登记任务时说明执行前再次核对。初始焦点进入“保留运行状态”,最终危险按钮写明“确认停止服务/重启服务/释放模型”;取消保留运行状态。
|
||||
确认使用独立 body Portal,隔离底层管理 Modal,Tab/Shift+Tab 只在确认中循环;Escape 只关闭确认,关闭后恢复原服务操作按钮,按钮不可用时回到管理 Modal。确认存在时禁用主 Modal 关闭按钮;提交期间禁用确认和取消按钮,两层 Escape 均不能关闭,也不能重复执行。失败关闭旧确认,在服务列表显示错误并刷新;再次操作须重新确认当前影响。成功只关闭确认。
|
||||
操作失败呈现实际状态和可重试错误,无法确认则显示未知。服务恢复不自动重放任务。
|
||||
关闭 Modal、关闭管理应用或取消常驻不停止服务,模型文件卸载另行操作。
|
||||
|
||||
服务列表提供首次加载、无服务、加载失败重试及保留旧快照的刷新失败状态;刷新期间或失败时禁用服务操作。
|
||||
OCR 忙碌或模型不可用时禁用加载与释放,并在执行模块再次检查。
|
||||
页面不显示无来源的进度条或资源数值。US-B6 至 US-B9 的应用中心、顺序、启用联动、笔记共同设置映射见
|
||||
[需求对应](./logic-design.md#6-与需求的对应关系)。
|
||||
|
||||
## 10. 项目活动汇总
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 状态 | 应用中心与本机推理已接入源码,整体验收待完成 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
@@ -29,47 +29,50 @@
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户隐藏任意内置应用
|
||||
- Given 用户隐藏任意可选内置应用
|
||||
- When 用户查看主导航
|
||||
- Then “运行记录”仍显示在应用区之后
|
||||
- And 设置中没有隐藏、卸载或固定运行记录的选项
|
||||
|
||||
### US-A2 从固定入口进入设置
|
||||
|
||||
作为用户,我希望设置入口始终位于侧栏底部,以便快捷工具状态变化时仍能进入配置。
|
||||
作为用户,我希望设置入口始终位于侧栏底部,以便应用状态变化时仍能进入配置。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 任意主页面和任意侧栏宽度
|
||||
- When 用户查看侧栏底部
|
||||
- Then 设置按钮保持可见且点击区域不与工具按钮重叠
|
||||
- Then 设置与应用中心为始终可见的独立按钮,点击区域不重叠
|
||||
- And 点击后继续遵守未保存内容的离开确认
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
本节的启用、常驻、排序和共同设置仅适用于魔法笔记、本机推理;知识库和智能心跳始终启用并固定显示。原知识库/智能心跳禁用场景已退役,保留故事 ID,并在 US-B6、US-B8 中以固定可用不变量替换验收。
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
作为用户,我希望魔法笔记、知识库和智能心跳显示在主导航,以便知道它们会打开完整页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 对应功能已启用且应用设置为显示
|
||||
- Given 固定应用,或已启用且常驻的可选应用
|
||||
- When 用户打开主侧栏
|
||||
- Then 应用位于“当前对话”和“运行记录”之间
|
||||
- And 点击应用在主内容区打开对应一级页面
|
||||
- And 原有待办或建议徽标继续显示
|
||||
- And 点击知识库、智能心跳或魔法笔记在主内容区打开对应一级页面;本机推理打开保留底层工作区的 Modal,验收见 US-F3
|
||||
- And 原有待办或建议徽标按已保存的显示设置继续显示
|
||||
- And 当前对话之后依次固定显示知识库、智能心跳,再显示可选应用;两个固定应用不参与启用、常驻或排序
|
||||
|
||||
### US-B2 隐藏低频应用
|
||||
### US-B2 取消低频应用常驻
|
||||
|
||||
作为用户,我希望隐藏不常用的内置应用,以便缩短主导航,但保留已有数据。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已有内容
|
||||
- When 用户关闭该应用的“显示在侧边栏”Switch
|
||||
- Then 主导航不再显示该应用
|
||||
- Given 应用已启用且已有内容
|
||||
- When 用户关闭该应用的“常驻左侧菜单”Switch 并保存成功
|
||||
- Then 主导航不再显示该应用,当前已打开页面保持不变
|
||||
- And 应用数据、计划和业务启停状态不变
|
||||
- And 用户可从同一设置位置恢复显示
|
||||
- And 用户可从应用中心打开该应用,或从同一设置位置恢复常驻
|
||||
|
||||
### US-B4 保留功能启停语义
|
||||
|
||||
@@ -78,35 +81,129 @@
|
||||
验收:
|
||||
|
||||
- Given 魔法笔记功能已启用
|
||||
- When 用户只隐藏其侧栏入口
|
||||
- When 用户只取消其侧栏常驻并保存成功
|
||||
- Then 笔记及待办处理能力继续按原设置工作
|
||||
- And 再次显示时恢复原页面和数据
|
||||
- And 重新常驻只恢复入口,不自动导航;再次打开时保留原页面状态和数据
|
||||
|
||||
### US-B5 恢复显隐保存失败
|
||||
### US-B5 保持设置保存一致
|
||||
|
||||
作为用户,我希望入口设置保存失败时继续使用原导航,以免界面状态与实际设置不一致。
|
||||
作为用户,我希望应用设置保存期间和失败后各入口保持一致,以免界面与实际配置不符。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用显隐更新尚未完成
|
||||
- When 用户切换“显示在侧边栏”Switch
|
||||
- Then 三个应用显隐 Switch 在请求期间暂时不可操作
|
||||
- And 更新成功后主导航立即变化
|
||||
- And 更新失败时 Switch 和主导航恢复原状态并显示可重试错误
|
||||
- Given 配置已加载且没有待确认的更新
|
||||
- When 用户修改启用、常驻、顺序或魔法笔记配置
|
||||
- Then 更新串行保存,在请求确认前,应用中心、应用页及全局设置中修改这组配置的所有控件均暂不可操作
|
||||
- And 各处保留最后确认值,不提前改变导航、业务可用性、徽标或会话入口
|
||||
- When 保存成功
|
||||
- Then 各入口同时采用确认配置,后续修改基于该配置提交
|
||||
- And 工具配置写入持久化成功后也同步各入口;较新的确认快照不被迟到的读取或保存响应覆盖
|
||||
- When 关闭后重新打开应用中心
|
||||
- Then 刷新最新配置期间锁定修改,读取失败保留重试入口,不允许用旧快照提交
|
||||
- When 保存明确失败
|
||||
- Then 各入口仍使用相同的最后确认配置,操作处显示可重试错误,保留焦点和编辑上下文,不重复通知
|
||||
- Given 响应中断,无法确认保存结果
|
||||
- When 用户查看设置
|
||||
- Then 显示正在确认保存结果,重读权威配置成功后才恢复修改;读取失败可重试读取,不自动重发变更或写回旧值
|
||||
|
||||
### US-B6 从应用中心访问内置应用
|
||||
|
||||
作为用户,我希望从固定应用中心找到应用,以便未常驻或已关闭的应用仍有访问和恢复入口。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面,包括全部可选应用均已关闭的情况
|
||||
- When 点击侧栏底部应用中心
|
||||
- Then 向上打开轻量锚定菜单,无模态遮罩;只列已启用应用,包括固定知识库、智能心跳和已启用的可选应用,不按打开历史或常驻筛选
|
||||
- When 点击菜单中的应用行
|
||||
- Then 菜单关闭;知识库、智能心跳和魔法笔记经既有离开检查在主内容区打开,本机推理打开保留底层工作区的 Modal,不自动常驻
|
||||
- When 点击菜单底部“管理应用”
|
||||
- Then 菜单关闭并打开既有管理 Modal,可按名称或用途搜索全部内置应用,包含未常驻及已关闭的可选应用
|
||||
- And 知识库和智能心跳行只提供“打开”,没有通用设置、启用、常驻或排序控件;原工作区实际配置及心跳单条计划 enabled 不变
|
||||
- And 首期没有商店、推荐、安装、市场 URL、YAML 操作或系统工具入口
|
||||
- Given 应用已启用但未常驻
|
||||
- When 用户选择打开
|
||||
- Then 本机推理打开 Modal,底层内容无需离开;其余应用经既有未保存内容离开检查进入页面,不自动常驻
|
||||
- Given 应用已关闭
|
||||
- When 用户在管理 Modal 中找到该应用
|
||||
- Then 显示关闭状态,保留禁用的“打开”按钮,可通过“设置”进入共同设置重新开启
|
||||
- Given 搜索无匹配结果
|
||||
- Then 显示无结果及清除搜索入口,搜索不修改启用和常驻
|
||||
- Given 配置正在加载或读取失败
|
||||
- Then 中心保留加载状态或重试入口,不用默认值冒充已保存配置
|
||||
|
||||
### US-B7 保存常驻及应用顺序
|
||||
|
||||
作为用户,我希望常驻和顺序按设备保存,以便重启后仍使用熟悉的导航。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 首次使用且未配置导航偏好
|
||||
- Then 知识库、智能心跳固定在前,可选应用默认均常驻,顺序为魔法笔记、本机推理;只按启用与常驻过滤可选项
|
||||
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
|
||||
- Then 仅可选应用顺序变化,知识库、智能心跳、当前对话、运行记录及底部固定入口不参与排序,重启后恢复保存结果
|
||||
- And 管理 Modal 编辑两个可选应用的完整顺序,上下移动以该顺序的相邻项为目标,固定应用不作为移动目标
|
||||
- Given 应用已关闭或取消常驻
|
||||
- When 用户重新开启或常驻
|
||||
- Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好
|
||||
- Given 保存失败
|
||||
- Then 按 US-B5 保留原常驻和顺序,在操作处提供重试并保留键盘焦点
|
||||
- Given 没有可见的可选应用或仅有一个
|
||||
- Then 固定入口和应用中心仍可用,没有移动目标的排序按钮禁用,不产生空白占位项
|
||||
|
||||
### US-B8 独立控制启用与常驻
|
||||
|
||||
作为用户,我希望关闭应用时保留数据和导航偏好,并能明确知道哪些业务操作已不可用。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已启用
|
||||
- When 仅取消常驻并保存成功
|
||||
- Then 业务能力和后台操作保持原状,仍可从应用中心打开
|
||||
- Given 知识库已有会话关联,智能心跳已有启用或暂停的计划
|
||||
- When 用户关闭全部可选应用或修改其常驻与顺序
|
||||
- Then 知识库和智能心跳始终启用,侧栏位置稳定,页面可打开;知识控件及关联保持可用,请求遵守知识库自身规则,心跳单条计划 enabled 原值不变
|
||||
- And 原“禁用知识库/智能心跳后隐藏业务入口”的验收由上述不变量替换;不存在这两个应用的应用级关闭状态或恢复设置页
|
||||
- Given 关闭的应用页面或本机推理 Modal 仍打开
|
||||
- Then 原位置显示“应用已关闭”和前往应用设置的恢复入口,保留草稿及页面状态,阻止新业务操作而不强制跳转
|
||||
- When 用户重新开启并保存成功
|
||||
- Then 仍停留的页面恢复可用;已离开时不自动导航,不重放或补发旧请求和任务
|
||||
- And 保留原常驻及顺序,未常驻应用不自动常驻
|
||||
- Given 启用变更保存失败
|
||||
- Then 按 US-B5 保留原业务状态和入口,不执行关闭或恢复副作用
|
||||
|
||||
### US-B9 迁移魔法笔记共同设置
|
||||
|
||||
作为魔法笔记用户,我希望从各入口编辑同一份设置,并在升级后保留已有选择。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户从应用中心或笔记页面进入笔记设置
|
||||
- Then 定位到同一共同设置页,使用同一表单和配置,不保留重复控件或“显示魔法笔记入口”开关
|
||||
- And 全局设置不再显示魔法笔记页签或跳转入口,已有配置值和业务数据保持不变
|
||||
- And 启用、常驻、显示未完成待办数量分别控制业务、导航和徽标;隐藏数量不修改待办
|
||||
- Given 升级前已有明确保存的布尔值和评论选择
|
||||
- When 迁移配置并重启
|
||||
- Then 保留已有 `false`、评论单选值及业务数据,仅未配置的应用启用、常驻和笔记相关布尔项默认 `true`
|
||||
- And 旧入口开关若实际控制笔记启用,保留其启用含义和原值,不将其改作常驻
|
||||
- Given AI 评论方式或评论形式未配置
|
||||
- Then 分别默认“即时”和“长评 + 要点”,均为单选,不同时启用所有模式
|
||||
- When 任一入口修改配置并保存成功
|
||||
- Then 应用中心、主导航、笔记页、会话及其他设置入口同步采用确认值;保存中和失败均遵守 US-B5
|
||||
|
||||
## 4. Epic C:使用系统工具
|
||||
|
||||
### US-C1 一键查看文件
|
||||
|
||||
作为本地工作用户,我希望点击左下角文件按钮后直接查看当前工作区文件。
|
||||
作为本地工作用户,我希望从右侧工作栏查看文件,并保留工作区面板的目标模式。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目有有效根目录
|
||||
- When 用户点击文件按钮
|
||||
- Then 助手工作栏展开
|
||||
- And 聚焦默认存在且不可关闭的工作区单实例
|
||||
- And 文件按钮通过选中状态表明该面板正在显示
|
||||
- When 用户展开右侧工作栏并选择已有工作区 Tab,或从“+”选择文件
|
||||
- Then 聚焦默认存在且不可关闭的工作区单实例,不创建重复实例;仅迁移或布局修复时补回缺失实例
|
||||
- And 保留跟随或固定模式,跟随模式继续按工作栏规则更新上下文
|
||||
- And 仅该实例 Tab 显示选中状态,焦点进入目录区域
|
||||
|
||||
### US-C2 处理无效工作区
|
||||
|
||||
@@ -116,67 +213,62 @@
|
||||
|
||||
- Given 工作区面板处于跟随模式
|
||||
- And 当前项目和默认工作区均不可用
|
||||
- When 用户点击文件按钮
|
||||
- When 用户在右侧工作栏选择工作区 Tab 或从“+”选择文件
|
||||
- Then 工作区面板仍打开
|
||||
- And 面板说明目录不可用并提供选择或修复目录的入口
|
||||
- And 应用不静默改用其他目录
|
||||
- Given 工作区面板固定的目标已失效,即使当前项目或默认目录可用
|
||||
- When 用户选择工作区 Tab
|
||||
- Then 保留固定目标并显示该目标的修复状态,不回退到其他目录或切换为跟随模式
|
||||
|
||||
### US-C3 快速进入终端
|
||||
|
||||
作为本地工作用户,我希望点击终端按钮后进入当前执行空间的终端。
|
||||
作为本地工作用户,我希望选择确切终端继续查看,并能通过“+”创建额外终端。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 已有终端 Tab,包括绑定其他执行空间的终端
|
||||
- When 用户选择其中一个 Tab
|
||||
- Then 仅激活该确切实例,保留执行空间、目录及历史,不按当前项目查找最近匹配实例或重新绑定
|
||||
- Given 已选 Tab 正在关闭、已退出、已中断或失败
|
||||
- Then 保留其真实状态,不因选中而重建
|
||||
- Given 当前执行空间已有正在启动或运行中的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 工作栏聚焦最近使用的匹配终端,不创建重复实例
|
||||
- Given 当前执行空间没有终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建一个绑定该执行空间的新终端
|
||||
- When 用户从右侧工作栏“+”选择终端
|
||||
- Then 每次创建绑定当前执行空间的额外实例,不复用或按目标去重
|
||||
- Given 当前没有项目
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦使用本机 Home 的终端
|
||||
- Given 当前执行空间只有已退出、已中断或失败的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建新终端并保留原终端的历史状态
|
||||
- When 用户从“+”选择终端
|
||||
- Then 创建使用本机 Home 的新终端,已有终端保持原目标和历史
|
||||
|
||||
### US-C4 使用远程项目终端
|
||||
|
||||
作为远程项目用户,我希望同一个终端按钮进入远程项目目录,以免误在本机执行命令。
|
||||
作为远程项目用户,我希望新建终端绑定远程项目目录,以免误在本机执行命令。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目绑定托管 SSH 执行空间
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦绑定该远程项目及其权威目录的终端
|
||||
- When 用户从右侧工作栏“+”选择终端
|
||||
- Then 创建绑定该远程项目及其权威目录的额外终端
|
||||
- And 面板持续显示远程主机和工作目录
|
||||
- And 连接失败时保留重连与诊断信息
|
||||
- And 连接失败时保留远端目标、重连与诊断信息,不回退为本机终端
|
||||
- When 切换当前项目后重新选择该终端 Tab
|
||||
- Then 仍激活原远程实例,不重绑到新项目
|
||||
|
||||
### US-C5 打开受控浏览器
|
||||
|
||||
作为用户,我希望浏览器按钮打开 GoodBuddy 受控浏览器,以便查看与 Agent 相同的会话。
|
||||
作为用户,我希望从右侧工作栏使用 GoodBuddy 受控浏览器,并按需创建独立页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦绑定当前 Conversation 的最近使用浏览器实例
|
||||
- And 不打开系统默认浏览器或无意创建重复实例
|
||||
- Given 现有浏览器实例都绑定到其他 Conversation
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏创建并聚焦绑定当前 Conversation 的新浏览器实例
|
||||
- Given 当前没有活动 Conversation
|
||||
- And 已存在浏览器实例
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦最近使用的浏览器实例并保持其目标
|
||||
- Given 当前没有活动 Conversation
|
||||
- And 不存在浏览器实例
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 创建待选择目标的空实例
|
||||
- Given 用户需要同一 Conversation 的另一个独立页面
|
||||
- Given 已有浏览器 Tab,当前 Conversation 与其目标不同或没有活动 Conversation
|
||||
- When 用户选择该 Tab
|
||||
- Then 激活该确切实例,保留绑定目标、页面及导航状态,不重绑、重建或改选最近匹配实例
|
||||
- Given 当前有活动 Conversation,无论是否已有匹配实例
|
||||
- When 用户从工作栏“+”选择浏览器
|
||||
- Then 每次创建拥有独立页面和导航状态的新实例
|
||||
- And 同一 Conversation 内共享隔离登录上下文
|
||||
- Then 每次创建绑定当前 Conversation 的额外实例,页面和导航状态独立,同一 Conversation 内共享隔离登录上下文
|
||||
- Given 当前没有活动 Conversation,无论是否已有浏览器实例
|
||||
- When 用户从“+”选择浏览器
|
||||
- Then 创建待选择目标的空实例,不复用已有实例
|
||||
- And 以上操作均使用 GoodBuddy 受控浏览器,不打开系统默认浏览器
|
||||
|
||||
### US-C6 查看本机资源
|
||||
|
||||
@@ -186,17 +278,26 @@
|
||||
验收:
|
||||
|
||||
- Given 资源监控面板尚未打开
|
||||
- When 用户点击左下角资源监控按钮
|
||||
- When 用户从右侧工作栏“+”选择资源监控
|
||||
- Then 助手工作栏创建并激活单实例资源监控面板
|
||||
- And 页面显示整机 CPU、内存、磁盘读写及 GoodBuddy 桌面进程摘要
|
||||
- And 页面每 2 秒更新一次,并用图表保留最近 5 分钟
|
||||
- And 可见活动期间每 2 秒采样一次,前次请求未结束时跳过新请求,图表最多保留 150 个真实时间样本,连续采样约 5 分钟
|
||||
- Given 资源监控实例已存在
|
||||
- When 用户选择其 Tab 或再次从“+”选择资源监控
|
||||
- Then 激活同一本机单实例,不新建重复面板
|
||||
- When 用户切换到其他工作栏 Tab、收起工作栏、隐藏窗口或最小化窗口
|
||||
- Then 资源监控停止请求新样本
|
||||
- When 用户再次显示窗口并返回资源监控
|
||||
- Then 首次采样建立区间指标基线,下一次采样开始显示 CPU 和磁盘 IO 速率
|
||||
- And 内存等瞬时指标可立即显示,基线未建立时不显示虚构零值
|
||||
- Given 某项指标在当前平台不可用
|
||||
- When 页面收到部分结果
|
||||
- Then 该指标显示“不可用”及原因,其他指标继续更新
|
||||
- Given 本次采样全部失败或超时
|
||||
- Then 显示失败原因和重试入口,保留已有历史并标明最后更新时间,不把旧值当作实时数据
|
||||
- And 失败或暂停采样期间保留真实时间缺口,不补零或伪造中间样本
|
||||
- When 可见活动期间自动重试或用户手动重试成功
|
||||
- Then 按真实采样结果恢复更新,较晚返回的失活前旧请求不覆盖当前结果
|
||||
|
||||
### US-C7 从任意应用打开工具
|
||||
|
||||
@@ -205,35 +306,51 @@
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面
|
||||
- When 用户点击文件、终端、浏览器或资源监控
|
||||
- When 用户展开右侧工作栏,选择已有工具 Tab 或通过“+”选择文件、终端、浏览器或资源监控
|
||||
- Then 主页面保持不变
|
||||
- And 右侧工作栏打开对应面板
|
||||
- And 工具不会覆盖主内容或伪装成一级页面
|
||||
- And 创建失败时工作栏保持展开并显示对应错误,主页面和其他实例不被清空
|
||||
- And 后台事件不自动展开工作栏或切换当前 Tab,左侧和应用中心不复制工具入口或选中状态
|
||||
|
||||
## 5. Epic D:可访问性和布局
|
||||
|
||||
### US-D1 使用键盘打开系统工具
|
||||
### US-D1 使用键盘管理应用与访问工具
|
||||
|
||||
作为键盘用户,我希望按顺序聚焦并打开左下角工具,以便不依赖鼠标。
|
||||
作为键盘用户,我希望浏览和管理应用,并通过右侧工作栏访问工具,以便不依赖鼠标。
|
||||
|
||||
验收:
|
||||
|
||||
- 每个系统工具和设置都是独立按钮
|
||||
- Tab 顺序与视觉顺序一致
|
||||
- Enter 和 Space 均可激活按钮
|
||||
- 每个纯图标按钮都有可访问名称和工具提示
|
||||
- 焦点样式不被侧栏边界裁切
|
||||
- Given 用户使用键盘访问侧栏底部
|
||||
- Then 应用中心和设置是独立按钮,Tab 顺序与视觉顺序一致,Enter 和 Space 可激活,图标按钮有可访问名称和工具提示
|
||||
- When 打开应用中心
|
||||
- Then 入口提供菜单展开状态和控制关系,焦点进入首个可用菜单项;上下方向键循环移动,Home、End 到首尾,Enter、Space 激活
|
||||
- When 按 Escape 关闭菜单
|
||||
- Then 焦点返回应用中心入口;Tab 关闭菜单后继续正常焦点顺序,点击外部或焦点离开也关闭
|
||||
- When 选择“管理应用”
|
||||
- Then 关闭菜单并进入既有 Modal,焦点进入搜索;卡片的打开、设置及详情中的启用、常驻、排序控件按正常 Tab 顺序可达,不伪装成单个菜单项;进入详情及返回时滚动归顶,返回后聚焦搜索
|
||||
- When 通过键盘修改常驻或顺序
|
||||
- Then Switch 使用共享开关语义并支持 Space,上移和下移按钮名称包含应用名,结果通过 polite 状态区播报,失败保留焦点
|
||||
- Given 用户进入右侧工作栏
|
||||
- Then Tab 选择、游标焦点、方向键及 Home、End 由工作栏负责,使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`,按确切实例表达选中状态
|
||||
- And “+”为普通按钮,工具状态纳入 Tab 可访问名称;激活后焦点进入面板,关闭工作栏后返回固定工作栏开关
|
||||
- And 焦点环不被边界裁切,应用中心及设置沿用共享浮层和原生浏览器遮挡处理
|
||||
|
||||
### US-D2 在窄窗口使用快捷栏
|
||||
### US-D2 在窄窗口访问应用中心与工作栏
|
||||
|
||||
作为小屏用户,我希望侧栏抽屉中的工具按钮保持可操作。
|
||||
作为小屏用户,我希望应用管理和工具实例在窄窗口中仍可操作。
|
||||
|
||||
验收:
|
||||
|
||||
- 工具按钮保持单行且不重叠
|
||||
- 不因宽度不足隐藏文件、终端、浏览器、资源监控或设置
|
||||
- 触控点击区域符合统一界面规范
|
||||
- 关闭侧栏抽屉后焦点返回原触发按钮
|
||||
- Given 侧栏宽度为 `220px` 至 `420px`,或窄窗口使用侧栏抽屉
|
||||
- Then 底部应用中心和设置不重叠,不因宽度不足隐藏入口或缩小点击区域
|
||||
- When 打开应用菜单或通过“管理应用”进入 Modal
|
||||
- Then 菜单向上锚定且限制在窗口内,可内部滚动;Modal 中长名称可换行,搜索、常驻、排序、设置及重新开启入口均可达
|
||||
- And 触控不依赖悬停,工具提示保持在窗口内,点击区域符合统一规范
|
||||
- When 关闭侧栏抽屉
|
||||
- Then 焦点返回原触发按钮
|
||||
- Given 右侧工作栏有多个工具实例
|
||||
- Then Tab 单行横向滚动且“+”持续可见,工作栏停靠回流,不遮罩主页面;布局和 Tab 交互仍由工作栏负责
|
||||
|
||||
## 6. Epic E:项目活动级联
|
||||
|
||||
@@ -249,6 +366,9 @@
|
||||
- Then 进入该确切 Conversation ID,清除搜索、任务选择和会话菜单,窄窗口关闭侧栏
|
||||
- Given 设置页有未保存修改
|
||||
- Then 会话跳转仍需通过既有离开检查,取消离开保留设置草稿和原项目
|
||||
- Given 用户选择的确切 Conversation ID 未在内存中或已被删除
|
||||
- When 请求进入该会话
|
||||
- Then 刷新持久化会话列表后仍只查找同一 ID;刷新失败或会话不存在时显示通知并保留原页面和选择,不回退到项目最新会话或其他会话
|
||||
|
||||
### US-E2 键盘、窄窗口与实时结束
|
||||
|
||||
@@ -286,7 +406,7 @@
|
||||
|
||||
## 7. 后续需求场景
|
||||
|
||||
本节对应新增计划,不计入应用中心首期完成条件。
|
||||
US-F1、US-F2 为后续市场计划;US-F3 至 US-F5 的本机推理已接入源码,当前支持边界见技术设计,验证范围见实施进度。
|
||||
|
||||
### US-F1 连接组织应用市场
|
||||
|
||||
@@ -295,33 +415,85 @@
|
||||
- Given 组织已部署提供应用市场的 ShareServer
|
||||
- When 用户配置市场 URL,并在来源要求时完成登录
|
||||
- Then 可以浏览该来源的应用目录、详情及可获取版本
|
||||
- Given 市场暂时不可达
|
||||
- Then 显示失败原因与重试入口,已安装和内置应用仍可打开,不切换公共市场
|
||||
- And 市场 URL 按设备保存,重启后恢复;认证信息使用受保护凭据存储,不作为可导出配置
|
||||
- Given 市场不可达、URL 无效、登录过期或认证失败
|
||||
- When 用户访问该来源
|
||||
- Then 显示具体原因及修改地址、重新登录或重试入口,已安装和内置应用仍可打开,不静默切换公共市场或其他来源
|
||||
- Given 用户修改市场来源或开始获取应用资源
|
||||
- Then 市场配置不改变模型、应用更新及 Runtime 等独立下载源;已开始的下载保持原来源,失败不静默切源
|
||||
|
||||
### US-F2 导入导出应用定义
|
||||
|
||||
对应 FR-15。
|
||||
|
||||
- Given 用户需要在私有部署间迁移应用
|
||||
- When 导出应用为 `.yml` 并在另一台设备导入
|
||||
- When 导出应用为 YAML,并在另一台设备选择 `.yml` 或 `.yaml` 文件导入
|
||||
- Then 预览应用身份、版本、可迁移配置和依赖,凭据与业务数据不包含在导出文件中
|
||||
- When 用户确认有效定义且所需依赖已满足,导入成功
|
||||
- Then 应用目录显示导入的身份、版本和配置,可按其启用状态打开或配置,不自动启动模型或任务
|
||||
- Given 同标识应用已存在
|
||||
- Then 用户可选择更新或取消,取消后原应用与配置保持不变
|
||||
- When 用户查看导入预览
|
||||
- Then 可选择更新或取消,取消后原应用与配置保持不变
|
||||
- When 更新成功
|
||||
- Then 显示已确认的新定义和配置
|
||||
- When 更新失败
|
||||
- Then 显示具体原因及重试入口,保留原应用定义、版本和配置,不留下部分更新
|
||||
- Given 文件格式无效、版本不支持或离线环境缺失程序包或模型资源
|
||||
- Then 显示具体问题与补充要求,不将应用误报为可运行,也不自动启动模型或任务
|
||||
|
||||
### US-F3 管理本机模型服务
|
||||
### US-F3 查看本机推理服务与资源
|
||||
|
||||
对应 [FR-16](./prd.md#fr-16-本机模型管理入口新增计划项)。
|
||||
|
||||
- Given 用户使用依赖本机模型的应用
|
||||
- When 从应用中心或常驻菜单打开“本机模型”
|
||||
- Then 可查看已接入模型的状态、管理方式及使用方,两处入口访问同一页面
|
||||
- Given 多个应用共用一个 GoodBuddy 受管模型服务
|
||||
- Then 展示同一服务及使用方,启动操作不重复创建进程
|
||||
- When 用户停止或重启仍有任务使用的受管服务
|
||||
- Then 先展示任务影响供用户决定,取消后服务保持运行;操作失败显示原因与实际状态
|
||||
- Given 模型服务由外部程序启动,且未接入其管理接口
|
||||
- Then 展示“外部管理”与连接配置,不提供停止或重启
|
||||
- When 用户关闭管理页或取消常驻
|
||||
- Then 模型运行状态不变
|
||||
- When 从常驻入口、应用菜单或管理 Modal 的“打开”进入“本机推理”
|
||||
- Then 均打开同一管理 Modal,来源菜单或管理 Modal 关闭,底层工作区、当前项目、会话和未提交草稿保持不变
|
||||
- And 直接显示服务列表,不出现任务页签、任务历史或外部连接;知识库、智能心跳和魔法笔记仍打开主内容区页面
|
||||
- And 初始焦点进入关闭按钮,背景不可交互,Tab/Shift+Tab 在 Modal 内循环;标题与关闭按钮保持可见,正文独立滚动
|
||||
- Given 没有影响确认且没有操作提交
|
||||
- When 点击关闭按钮或按 Escape
|
||||
- Then 关闭并卸载管理 Modal,底层工作区继续显示,模型服务不停止;点击遮罩不关闭
|
||||
- And 焦点恢复到仍可用的原触发元素;原入口已移除时回到应用中心,窄侧栏已隐藏时回到侧栏开关
|
||||
- When 查看服务列表
|
||||
- Then 显示服务名、模型、引擎、简短状态及错误;共享服务只展示一次
|
||||
- Given 向量服务独立进程正在运行
|
||||
- Then 显示带 PID 和进程范围的 CPU、工作集内存;CPU 首次采样显示预热状态
|
||||
- Given 服务 worker 与宿主共享进程,或进程已退出、采样失败
|
||||
- Then 在服务行显示不能独立统计或当前不可用的原因,不展示虚构零值或借用整个应用进程指标
|
||||
- Given ASR 有活动请求但没有 Worker 就绪观测
|
||||
- Then 显示“按需运行(状态未知)”,不推断服务正在运行
|
||||
|
||||
### US-F4 单任务取消入口(已移除)
|
||||
|
||||
保留故事 ID。FR-16 已收窄为服务监视,Modal 不再提供任务取消按钮。执行路径的原有取消行为和活动请求登记继续用于服务影响校验,见[逻辑设计](./logic-design.md#81-本机推理管理)。
|
||||
|
||||
### US-F5 管理实际执行服务生命周期
|
||||
|
||||
对应 FR-16;当前引擎支持清单见[技术设计](./technical-design.md#111-本机推理任务与执行服务)。
|
||||
ASR 取消独立 Worker,向量停止核对所有活动请求,OCR 有活动或排队请求时拒绝释放。
|
||||
运行阶段包括排队、加载和执行;当前未接入统一进度及来源项目对象关联。资源指标按服务进程统计,见 US-F3。
|
||||
|
||||
- Given 多个应用共用一个由 GoodBuddy 受管子进程承载的模型服务
|
||||
- When 用户启动、停止或重启服务
|
||||
- Then 仅提供引擎实际支持的操作,按同一服务串行处理;重复请求不重复创建进程或并发执行同一服务的重启
|
||||
- And 以真实进程退出和服务就绪确认结果,不将命令已发送当作操作成功
|
||||
- Given 用户准备停止、重启或释放服务或模型
|
||||
- When 用户发起操作
|
||||
- Then 在管理 Modal 上方打开紧凑影响确认,列出使用方、任务及影响;无已登记活动任务时说明执行前再次核对,取消后不发送操作指令且运行状态不变
|
||||
- And 初始焦点进入“保留运行状态”,Tab/Shift+Tab 在确认内循环,底层管理 Modal 不可交互;Escape 只关闭确认并恢复服务操作按钮焦点,按钮不可用时回到管理 Modal
|
||||
- When 确认提交尚未完成
|
||||
- Then 禁止重复执行,确认和取消按钮均禁用,两层 Modal 都不能通过关闭按钮或 Escape 关闭
|
||||
- When 操作成功
|
||||
- Then 只关闭影响确认,管理 Modal 保持打开
|
||||
- Given 执行前出现确认清单未包含的新任务
|
||||
- Then 拒绝执行,关闭旧确认并显示错误,自动刷新状态;刷新期间或失败时禁用服务操作,再次发起操作须确认新的影响清单
|
||||
- Given 停止失败,或重启时停止成功但启动失败
|
||||
- Then 显示实际状态、原因和可重试错误,不假报已停止或已恢复;无法确认时显示未知
|
||||
- When 服务再次启动或重启成功
|
||||
- Then 已中断任务不自动重放,由来源应用显式重新发起
|
||||
- Given 模型连接由外部程序管理
|
||||
- Then 在既有模型与连接设置中查看,不将其列入本机服务或提供停止、重启
|
||||
- Given 模型在进程内运行
|
||||
- Then 仅提供引擎实际支持的加载、释放,不显示虚构 PID、进程数或进程启动停止状态
|
||||
- When 用户关闭管理 Modal、关闭管理应用或取消常驻
|
||||
- Then 模型服务运行状态不变,取消常驻不关闭当前 Modal,卸载模型文件为独立操作
|
||||
|
||||
@@ -101,8 +101,8 @@ Runtime
|
||||
|
||||
### 3.2 当前上下文只提供默认值
|
||||
|
||||
任务中心只有一个工作栏实例,默认跟随当前项目,不跟随当前 Conversation。用户可以在同一
|
||||
实例内显式切换到所有项目,但不能为不同项目打开多个任务中心 Tab。其他可绑定
|
||||
任务中心只有一个工作栏实例,任务列表固定跟随当前项目,不随当前 Conversation 筛选;顶部
|
||||
统计跟随当前 Conversation。不提供全局或所有项目入口。其他可绑定
|
||||
目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是
|
||||
使用门槛:
|
||||
|
||||
@@ -217,8 +217,8 @@ Runtime
|
||||
- 应用注册表分别声明实例策略、默认打开、必须存在、是否可关闭和是否允许排序。`single` 或
|
||||
`multiple` 只控制实例数量;`required` 控制布局修复,不能兼作关闭或排序策略。
|
||||
- 在工作栏“+”目录中重复选择单实例应用时聚焦已有实例;选择浏览器、终端等多实例应用时
|
||||
每次创建新实例。主侧栏系统工具快捷入口按当前上下文聚焦匹配实例,不能因连续点击无意
|
||||
创建重复实例。
|
||||
每次创建新实例。选择已有 Tab 只激活该实例,保留其目标与历史。主侧栏应用中心不重复
|
||||
提供文件、终端、浏览器或资源监控入口。
|
||||
- 支持方向键、Home、End、Enter、Space、可关闭面板的关闭命令和正确焦点恢复。不可关闭
|
||||
实例不渲染关闭按钮,也不响应关闭快捷命令。
|
||||
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
|
||||
@@ -260,25 +260,26 @@ Runtime
|
||||
Task Center 保留为工作栏中的稳定入口,并在现有基础上适度完善:
|
||||
|
||||
- 只索引 Task;每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task。
|
||||
- 显示名称、关联 Conversation、所属项目(无项目时标注“未绑定项目”)、Ask/Execute、状态、最近进展、
|
||||
真实活动时间和需要关注信息。
|
||||
- 顶部显示当前会话消息总数和 Agent 累计回复时间;列表显示本项目任务的名称、关联 Conversation、
|
||||
所属项目、Ask/Execute、状态、最近进展、真实活动时间、累计执行时间和需要关注信息。
|
||||
- 点击 Task 打开其关联 Conversation 并定位 Task。
|
||||
- 完整消息留在 Conversation;工具、Subagent、审批、错误和成果按 Task 关联到 Runtime、
|
||||
活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。
|
||||
- 智能心跳的报告、建议、历史和配置不进入任务中心。
|
||||
|
||||
任务中心是应用级单例,默认存在且不可关闭。它的数据范围默认跟随当前项目,并在标题或范围
|
||||
控件中明确显示项目;用户可以切换到“所有项目”,该选择只改变同一实例的列表
|
||||
范围,不创建第二个任务中心 Tab。切换回“跟随当前项目”后,项目变化立即刷新列表。后台状态
|
||||
任务中心是应用级单例,默认存在且不可关闭。当前项目是唯一范围,标题明确显示项目;切换
|
||||
项目刷新任务和审批,同项目切换会话只更新顶部统计。无项目时提示选择项目。后台状态
|
||||
可以更新徽标和排序,但不能抢占用户当前工作。
|
||||
|
||||
当前实现已按活动项目过滤 Scheduled Task 和审批,并提供“当前项目”和“所有项目”
|
||||
范围控件。
|
||||
回复时间按每轮请求区间累加,包含轮内工具和审批等待、不计轮间空闲;任务用时汇总历次执行
|
||||
请求,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不虚构时间。
|
||||
项目任务标题右侧先显示“+”新建按钮,再显示筛选图标;状态筛选默认收起、默认全部。
|
||||
|
||||
主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用
|
||||
任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。
|
||||
新建定制任务使用 Modal 选择当前或新
|
||||
Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录、工具和审批摘要。
|
||||
新建定制任务使用共享 Modal,默认立即执行和当前 Conversation,可选本项目其他可用会话或
|
||||
新会话,以及单次定时、每日或每周。内容必填,名称可留空从内容生成;工作模式使用关联
|
||||
会话的执行时配置,未设置时使用项目默认值,不在创建表单另设模式。
|
||||
|
||||
详细产品边界以 [Task Center PRD](../task-and-job/task-center-prd.md) 和
|
||||
[Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 为准。
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# 会话监督
|
||||
# 监督者与会话监督
|
||||
|
||||
会话监督定义对长会话、工具执行和异常状态的观察、判断与介入边界。
|
||||
监督者的目标设计统一工作回顾、会话监督与故事线图谱,以智能心跳作为定时唤醒机制。计划将已有智能心跳入口升级为监督者,避免新增重复应用。当前会话监督尚在设计中,图谱仅有模拟原型,统一应用尚未接入产品。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [会话监督 PRD](./prd.md) | 监督目标、证据、介入方式和产品验收 |
|
||||
| [监督者应用产品设计](./supervisor-prd.md) | 应用定位、心跳分工、知识库关系与目标范围 |
|
||||
| [逻辑设计](./logic-design.md) | 整体架构图、图谱对象关系图、状态规则与需求映射 |
|
||||
| [UI 设计](./ui-design.md) | 应用导航、工作回顾、侧栏反馈及页面状态 |
|
||||
| [User Stories](./user-stories.md) | 监督者与故事线图谱的用户操作和验收场景 |
|
||||
| [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 |
|
||||
|
||||
当前尚无独立 User Stories、功能逻辑、UI、技术设计和进度文档。
|
||||
统一应用的技术设计与实施进度尚未制定;逻辑待定项见[逻辑设计](./logic-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# 监督者逻辑设计
|
||||
|
||||
日期:2026-09-16。状态:目标设计,尚未接入产品。图中的连线表示逻辑依赖与数据流,不代表已实现的调用接口或数据库结构。
|
||||
|
||||
本文承接[监督者产品设计](./supervisor-prd.md)、[故事线图谱设计](../story-graph/prd.md)和[User Stories](./user-stories.md),定义触发、整理、展示与用户后续操作之间的关系。既有会话监督的证据和介入边界继续由[会话监督 PRD](./prd.md)约束。
|
||||
|
||||
## 整体逻辑架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Entry["入口与触发"]
|
||||
App["监督者应用"]
|
||||
Scope["关注范围、时间区间与整理策略"]
|
||||
Manual["用户主动回顾"]
|
||||
Heartbeat["智能心跳:按计划唤醒"]
|
||||
end
|
||||
|
||||
subgraph Sources["已有内容与状态"]
|
||||
Conversation["会话记录"]
|
||||
Notes["魔法笔记"]
|
||||
Work["任务状态与执行记录"]
|
||||
Knowledge["现有知识库资料与检索引用"]
|
||||
end
|
||||
|
||||
subgraph Supervisor["监督者 Agent"]
|
||||
Collect["收集范围内的有界可见证据"]
|
||||
Review["理解进展与变化,判断是否需要整理"]
|
||||
Result["带来源的整理结果"]
|
||||
Quiet["记录检查结果,保持安静"]
|
||||
end
|
||||
|
||||
subgraph Outputs["结果与展示"]
|
||||
Recap["工作回顾:阶段决定、变化、未解决事项"]
|
||||
Feedback["监督反馈:聊天右侧展示"]
|
||||
Graph["故事线图谱:事件、实体、关系与演变"]
|
||||
end
|
||||
|
||||
subgraph Actions["用户后续操作"]
|
||||
Verify["核对来源、确认或纠正整理结果"]
|
||||
Continue["预览所选上下文后继续讨论"]
|
||||
Save["预览并确认新建或补充本地知识条目"]
|
||||
end
|
||||
|
||||
App --> Scope
|
||||
App --> Manual
|
||||
Scope --> Collect
|
||||
Manual --> Collect
|
||||
Heartbeat --> Collect
|
||||
Conversation --> Collect
|
||||
Notes --> Collect
|
||||
Work --> Collect
|
||||
Knowledge --> Collect
|
||||
Collect --> Review
|
||||
Review -->|需要整理| Result
|
||||
Review -->|无有效新变化| Quiet
|
||||
Result --> Recap
|
||||
Result --> Feedback
|
||||
Result --> Graph
|
||||
Recap -->|定位阶段| Graph
|
||||
Feedback -->|定位相关对象| Graph
|
||||
Recap --> Verify
|
||||
Graph --> Verify
|
||||
Verify -->|更新确认状态或用户修订| Result
|
||||
Graph --> Continue
|
||||
Continue --> Conversation
|
||||
Graph --> Save
|
||||
Save -->|仅写入可写的本地条目| Knowledge
|
||||
```
|
||||
|
||||
心跳负责唤醒,监督者负责判断;模型不因被称为 Agent 而自动获得工具权限。收集证据由应用按所选范围提供,现有心跳不具备的知识库或笔记接入属于后续工作。
|
||||
|
||||
工作回顾、监督反馈和图谱引用同一次整理的结论与来源。展示可以裁剪为阶段摘要、会话卡片或图形,但不能分别维护相互矛盾的结论。一次整理可以没有图谱变更,也可以有整理结果但无需主动通知。
|
||||
|
||||
应用维护来源正文;监督者维护其产生的阶段总结、事件关联、实体描述与关系。图谱是这些对象的视图。图中“纠正”只更新整理内容或关系,修改来源正文仍需进入对应应用的编辑流程。
|
||||
|
||||
## 图谱对象关系
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Story["故事线"]
|
||||
Event["时间事件"]
|
||||
Change["事件与实体的关联:提出、补充、验证、修订"]
|
||||
Entity["知识实体"]
|
||||
Relation["知识关系:类型、理由、确认状态"]
|
||||
Summary["阶段总结"]
|
||||
Interval["时间区间与来源范围"]
|
||||
Reference["来源引用:所属对象、位置、可用版本信息"]
|
||||
Original["原内容:会话、笔记、本地资料或已保存外部片段"]
|
||||
|
||||
Story -->|组织| Event
|
||||
Story -->|关联,可跨故事共享| Entity
|
||||
Story -->|按阶段回顾| Summary
|
||||
Summary -->|限定覆盖范围| Interval
|
||||
Summary -->|归纳范围内事件| Event
|
||||
Summary -->|概括当时的认识| Entity
|
||||
Event -->|一个事件可影响多个实体| Change
|
||||
Change -->|一个实体可经历多个事件| Entity
|
||||
Entity -->|关系起点| Relation
|
||||
Relation -->|关系终点| Entity
|
||||
Event -->|原始依据| Reference
|
||||
Change -->|变化依据| Reference
|
||||
Relation -->|关联依据| Reference
|
||||
Entity -->|支撑资料| Reference
|
||||
Summary -->|结论依据| Reference
|
||||
Reference -->|定位,不复制正文| Original
|
||||
```
|
||||
|
||||
这里的关联对象用于说明关系自身也有类型和依据,不指定存储表。知识实体与来源为多对多关系;实体可以被多个故事引用,不能仅凭同名就合并。来源可用的版本信息沿用原内容能力,不要求为此复制完整来源历史。
|
||||
|
||||
外圈时间轴呈现事件及阶段标记,内部平铺实体;时间到实体的连线对应事件关联,实体间连线对应知识关系。时间轴上的同日聚合点只负责显示密集事件,不形成新的知识实体。
|
||||
|
||||
## 状态与处理规则
|
||||
|
||||
| 规则 | 条件或状态 | 处理与用户可见结果 | 需求与场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| L1 范围 | 手动或定时触发 | 按本次所选范围、时间和输入预算收集证据;启用应用不扩大来源范围 | FR-S3、S4;US-S01 |
|
||||
| L2 检查 | 没有有效新变化 | 记录检查结果,保留旧回顾,不重复通知;用户主动查看时仍可读取原结果 | FR-S4、S5;US-S03 |
|
||||
| L3 整理 | 产生新的阶段认识或关联 | 回顾、反馈和图谱引用同一整理结果;是否主动反馈按反馈策略决定 | FR-S2、S5;US-S02、S04 |
|
||||
| L4 失败 | 取消、失败或部分来源不可用 | 保留上次成功结果;显示当前运行状态、已覆盖与遗漏范围,支持重试;部分结果不标为完整 | FR-S6;US-S12 |
|
||||
| L5 纠正 | 用户修改描述或确认关系 | 后续自动整理提出补充或修订建议,不静默覆盖用户内容;移除关系可撤销且不删除来源 | FR-S6、S9、FR-G4;US-S06、S10 |
|
||||
| L6 历史 | 选择过去时刻或时间区间 | 使用当时已有的事件与认识;来源旧版不可恢复时提示,不以当前原文冒充旧版 | FR-S9、FR-G3、G5;US-S05、S07、S13 |
|
||||
| L7 目标 | 侧栏跟随或固定 | 跟随时显示当前会话,固定时保持所选目标;切换聊天不改变固定目标 | FR-S5;US-S02 |
|
||||
| L8 沉淀 | 用户选择写入知识库 | 先预览并确认,再新建或补充本地条目;已有条目提供打开入口,外部引用保持只读 | FR-S7、S8;US-S08、S09 |
|
||||
| L9 继续 | 用户选择继续讨论 | 预览并确认上下文后进入会话;新内容在后续整理时纳入,不立即作为已确认知识 | FR-S3、S5;US-S11 |
|
||||
| L10 展示 | 改变布局或输入方式 | 保留范围、回放时刻及选择;图形和文字列表访问相同对象与来源 | FR-G2、G5、G6;US-S15 |
|
||||
|
||||
整理运行状态与内容确认状态分开:一次运行成功不意味着所有自动归纳均已被用户确认。运行从待处理进入整理中,再结束为成功、无新变化、部分完成、失败或已取消;重试作为新一次整理显示,不能将失败运行改称成功。具体运行标识与现有心跳状态如何映射留待技术设计。
|
||||
|
||||
规则冲突时,先满足所选范围与既有观察边界,再保留用户修订和来源事实,最后应用自动归纳和展示策略。新增证据与已确认结论冲突时显示待核对差异,不能自动断言旧观点失效。
|
||||
|
||||
## 完整性与待定项
|
||||
|
||||
本设计覆盖触发、证据输入、静默检查、正常整理、取消与失败、跨视图共享结果、用户纠正、知识库写入及历史回放。它补充产品逻辑,不代表接口、持久化或迁移方案已经完成。
|
||||
|
||||
仍待产品决策:停用应用时计划及运行中任务的处理方式、事件触发与阶段识别条件、反馈频率与静默判定、实体合并与拆分的交互、时间轴比例和集中度含义。既有入口偏好、计划与运行记录的保留要求见 FR-S1、S2 和 US-S14。
|
||||
|
||||
技术设计需确认现有实体标识能否复用、来源定位与历史版本可用性、增量整理和并发运行如何避免重复结果、已有心跳计划如何兼容。按照现有能力选择最小实现,不预设新的历史快照、恢复框架或独立知识存储。
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
## 1. 背景
|
||||
|
||||
2026-09-16 产品归属更新:本 PRD 继续定义会话监督能力。统一应用命名为“监督者”,整合智能心跳入口、工作回顾和故事线图谱,目标定位与知识库分工见[监督者应用产品设计](./supervisor-prd.md)。该整合尚未实现,不改变本文既有证据和介入边界。
|
||||
|
||||
GoodBuddy 的魔法笔记已经提供一种有价值的交互:用户持续写作,AI 在右侧以长评、建议和
|
||||
警告进行评论,用户可以选择综合、扩展、润色、质疑或发散方向。该能力是内容分析,不是
|
||||
会话监督:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 监督者应用产品设计
|
||||
|
||||
日期:2026-09-16。状态:目标设计已确认,尚未接入产品。
|
||||
|
||||
本文定义监督者应用的定位、功能边界与知识库关系。[会话监督 PRD](./prd.md)继续负责对执行过程的观察、证据与介入边界;本文将该能力纳入更完整的工作回顾应用。
|
||||
|
||||
## 产品定位
|
||||
|
||||
监督者是持续关注指定工作范围的智能 Agent。用户可以主动要求回顾,也可以通过智能心跳定期唤醒它。它根据可见记录判断是否需要反馈、整理阶段或更新知识关联,没有值得反馈的变化时保持安静。
|
||||
|
||||
| 名称 | 职责 |
|
||||
| --- | --- |
|
||||
| 监督者 | 应用名称,负责观察、回顾和解释工作进展 |
|
||||
| 智能心跳 | 按计划唤醒监督者的机制,保留计划与运行管理 |
|
||||
| 监督反馈 | 会话右侧的阶段回顾、问题提示与证据入口 |
|
||||
| 故事线图谱(Story Graph) | 呈现事件、知识实体及其随时间发展的关系 |
|
||||
|
||||
FR-S1:将现有智能心跳入口整合、升级为监督者,不增加另一个平级重复应用。当前通过应用中心管理;未来应用市场提供同一应用的发现与启用入口。市场建设不应成为监督者落地的前置条件。
|
||||
|
||||
FR-S2:应用包含“工作回顾”和“故事线图谱”两个主视图。设置管理关注范围、心跳计划、整理时机和反馈方式。原有计划、运行记录及用户启用、常驻偏好应保留;具体标识映射与数据迁移由后续技术设计确定,本轮不更名生产页面或存储字段。
|
||||
|
||||
## 范围与触发
|
||||
|
||||
FR-S3:首次启用时选择项目、会话及可用来源,生成前显示范围和时间区间。应用启用不等于默认观察全部内容。已有心跳的 Global、单项目和多项目范围语义由[智能心跳 PRD](../smart-heartbeat/prd.md)定义,统一应用时保留。
|
||||
|
||||
FR-S4:支持手动回顾,并复用已有定时检查机制。每次整理只处理所选范围内的有界输入;无新增有效信息时不重复发送反馈。事件触发、阶段自动判定和增量整理策略尚待设计,不因 Agent 定位而默认获得工具执行能力。
|
||||
|
||||
FR-S5:会话右侧在跟随模式下展示当前会话的监督反馈;固定到 Conversation、Task 或 Experiment 时保持所选目标并显示其名称,切换聊天不改变监督目标。跨会话总结在完整应用中展示。反馈包含结论、来源和“在图谱中查看”定位入口。用户主动选择“发到当前会话”才加入聊天消息,同一结果不重复触发侧栏和全局通知。
|
||||
|
||||
FR-S6:监督者可提出整理与修改建议;已有会话监督的观察边界继续生效。自动更新图谱不能静默覆盖用户修改的描述、已确认的关系或已有知识库正文。取消整理后保留上次结果;失败说明受影响范围并允许重试,部分结果不得显示为完整回顾。
|
||||
|
||||
## 与知识库的关系
|
||||
|
||||
FR-S7:知识库管理资料、索引、检索和可复用知识;监督者管理工作事件、阶段总结与演变关系。故事线图谱共用现有来源,引用知识库对象,不复制一套资料正文,也不取代已有知识库图谱。
|
||||
|
||||
一个知识实体可以关联多份资料,一份资料也可支撑多个实体。图谱的关系与阶段记录由监督者维护,原始内容由所属会话、笔记或知识库维护。新增功能的概念实体与现有图谱实体是否共用标识,需结合现有模型制定技术方案。
|
||||
|
||||
FR-S8:本地知识库提供双向定位:资料详情可进入相关故事线图谱,图谱详情可返回资料及引用位置。已有本地条目显示“打开知识库条目”;新归纳提供“新建条目”或“补充已有条目”,先预览内容和引用再保存。外部引用沿用现有能力,打开已保存的有界片段及可用定位信息,不承诺重新打开远端原文;缺少位置时明确提示。外部知识库保持只读;不得因图谱整合而增加对 Dify、FastGPT、RAGFlow 的写入或批量同步。
|
||||
|
||||
FR-S9:来源事实、自动归纳、待核对关联和用户确认内容分别标注。移除关联不删除原文,纠正操作可撤销。来源失效时显示不可用状态;回放历史时使用当时已有的归纳与依据,无法恢复旧版原文时明确提示,不能以当前版本冒充历史内容。
|
||||
|
||||
## 落地范围
|
||||
|
||||
触发、整理与结果共享规则,以及整体架构和图谱对象关系见[逻辑设计](./logic-design.md)。
|
||||
|
||||
首个完整流程为:启用监督者、选择范围、生成回顾、进入图谱、核对来源、携带选定上下文继续讨论。知识库双向定位与内容补充随后接入同一流程。图谱关系辅助检索需单独验证效果,不作为首版默认检索策略。
|
||||
|
||||
当前智能心跳已有计划和手动运行能力;会话监督仍在设计中;故事线图谱只有模拟数据 Demo;应用市场尚未实现。本文不构成这些目标能力已交付的证据。
|
||||
|
||||
具体 UI 与验收见[UI 设计](./ui-design.md)和[User Stories](./user-stories.md)。图谱对象与操作由[故事线图谱设计](../story-graph/prd.md)定义。技术设计仍需确定实体标识、来源定位、历史版本能力、计划兼容及增量整理方式。
|
||||
@@ -0,0 +1,27 @@
|
||||
# 监督者 UI 设计
|
||||
|
||||
日期:2026-09-16。状态:目标设计,遵循根目录 [UI-DESIGN.md](../../../UI-DESIGN.md)。需求对应[监督者应用产品设计](./supervisor-prd.md) FR-S1 至 FR-S9。
|
||||
|
||||
## 应用入口
|
||||
|
||||
应用中心中的智能心跳入口升级为“监督者”,沿用既有启用、常驻与排序能力。未来应用市场使用同一应用身份。用户停用自动心跳时仍可手动回顾;停用应用时明确说明关联自动计划的处理,既有结果仍保留。停用与运行中任务的具体行为需在逻辑设计中确定。
|
||||
|
||||
应用页使用共享 PageTabs 展示“工作回顾 / 故事线图谱”,右上角提供设置。设置包含关注范围、心跳计划和反馈方式,已有运行记录可从工作回顾进入,保留失败详情与手动运行入口。
|
||||
|
||||
## 工作回顾
|
||||
|
||||
页面顶部持续显示项目或 Global 范围、来源选择与时间区间。首次使用引导用户选择来源并生成回顾。默认查看最近一次成功结果,包含阶段决定、变化与未解决事项;每项附来源和图谱定位入口。
|
||||
|
||||
手动“回顾当前进展”是主要操作。生成中显示进度与取消;空范围提供选择来源入口;有来源但没有有效变化时显示检查时间和“暂无新进展”。失败保留上次结果并标明时间,提供重试。部分来源不可用时列出遗漏范围。
|
||||
|
||||
## 会话侧栏
|
||||
|
||||
右侧通用助手工作栏中的监督面板展示“监督反馈”,沿用现有[工作栏设计](../assistant-workbar/prd.md)的布局和执行边界。跟随模式对应当前会话;固定模式持续显示所选 Conversation、Task 或 Experiment,切换聊天不改变目标。反馈卡片显示所属目标、阶段、结论、未解决事项及来源。
|
||||
|
||||
“在图谱中查看”定位到对应阶段或节点;“继续讨论”先预览节点摘要和来源引用,允许用户移除不需要的内容,再进入会话。“发到当前会话”由用户主动触发。跨会话内容需显式显示范围,不能伪装成当前会话的观察。
|
||||
|
||||
## 故事线图谱
|
||||
|
||||
使用 master-detail 页面壳层,左侧选择故事线,中间浏览图谱,右侧按选中对象切换详情。平铺和圆形视图使用共享 SegmentedControl;同级页面导航仍使用 PageTabs。布局、连线与回放行为见[图谱设计](../story-graph/prd.md)。
|
||||
|
||||
窄屏时故事列表收为选择入口,详情进入可返回的独立区域;保留文字事件列表作为图谱的替代入口。节点、连线与时间事件都可从列表选择,键盘焦点可见。颜色只辅助区分故事线,关系类型还需文字说明。缩放与平移不改变右侧当前选择。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 监督者 User Stories
|
||||
|
||||
日期:2026-09-16。以下为目标验收场景,尚未接入生产验证。需求编号引用[监督者产品设计](./supervisor-prd.md)与[故事线图谱设计](../story-graph/prd.md)。
|
||||
|
||||
| 编号 | 用户故事 | Given / When / Then | 需求 |
|
||||
| --- | --- | --- | --- |
|
||||
| US-S01 | 启用后知道监督者关注哪些工作 | 给定未配置的新用户;当从应用中心启用并选择项目、来源和时间;则生成前显示范围,未选来源不被自动纳入 | FR-S1、S3 |
|
||||
| US-S02 | 隔几天回来能继续上次工作 | 给定已有阶段记录;当打开工作回顾;则看到已有决定、未解决事项和来源,并可定位图谱 | FR-S2、S5 |
|
||||
| US-S03 | 自动检查不过度打扰 | 给定已启用心跳计划;当一次检查没有有效新变化;则记录检查结果,不重复推送旧反馈 | FR-S4、S5 |
|
||||
| US-S04 | 查看某个事件影响了哪些认识 | 给定时间点含多个事件;当展开并选择一个事件;则相关实体与连线高亮,详情提供事件原文位置 | FR-G2 |
|
||||
| US-S05 | 追溯同一个知识实体的多次变化 | 给定实体先后被提出、补充和修订;当选择该实体;则同时看到多个时间点及其关系,不复制为多个同名实体 | FR-G1、G3 |
|
||||
| US-S06 | 知道不同故事为什么有关 | 给定跨故事关联;当选择连接线;则展示关联类型、理由和两端依据,可确认、纠正或移除 | FR-S9、FR-G4 |
|
||||
| US-S07 | 复盘一段时间 | 给定已有过程记录;当选择时间区间;则总结只覆盖该范围,展示决定、变化和未解决事项;回放不使用未来结论 | FR-S9、FR-G5 |
|
||||
| US-S08 | 回到知识库核对依据 | 给定知识实体引用已有资料;当点击相关资料;则本地资料打开原条目及位置,且支持返回图谱;外部引用打开已保存片段与可用定位信息,缺少位置时提示 | FR-S7、S8 |
|
||||
| US-S09 | 补充知识而不重复保存 | 给定候选归纳与已有本地条目;当选择补充并确认预览;则修改原条目并保留引用;外部只读资料不提供写回操作 | FR-S8 |
|
||||
| US-S10 | 纠正错误关系 | 给定自动归纳有误;当修改或移除关系;则原文保留,可撤销,后续自动整理不静默覆盖用户修改 | FR-S6、S9 |
|
||||
| US-S11 | 带着已有认识继续讨论 | 给定选中的知识实体;当预览并确认摘要与来源;则会话携带所选上下文,后续内容可纳入下一次整理 | FR-S3、S5 |
|
||||
| US-S12 | 生成失败后仍能使用旧结果 | 给定已有成功回顾;当新一次整理失败或取消;则旧结果仍可访问,失败或部分完成状态明确,可重试 | FR-S6 |
|
||||
| US-S13 | 来源变化后不会误读历史 | 给定来源已更新或失效;当回放旧阶段并打开引用;则显示可用的历史依据,缺失旧版本时提示,不能将最新版称作当时原文 | FR-S9 |
|
||||
| US-S14 | 升级入口后原计划仍可管理 | 给定已有心跳计划和启用偏好;当应用升级为监督者;则保持计划、运行记录与偏好,不出现第二个重复应用入口 | FR-S1、S2 |
|
||||
| US-S15 | 在窄屏或仅用键盘时查看图谱 | 给定无法方便点击图形的用户;当使用事件与节点列表选择对象;则能查看相同详情、关系与来源,并返回原选择位置 | FR-G6 |
|
||||
@@ -10,6 +10,8 @@ FastGPT/RAGFlow 完整 App 与专属配置、真实远程预检索仍待验收
|
||||
|
||||
## 文档导航
|
||||
|
||||
后续与监督者的关系:故事线图谱引用现有知识库资料,补充工作事件与知识演变视图;计划支持双向定位和预览后补充本地条目,不复制资料正文、不改变外部知识库只读边界。该集成尚未实现,权威定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md) FR-S7 至 FR-S9。
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [知识库检索与分块增强 PRD](./prd.md) | 检索、分块、诊断和产品验收 |
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Smart Heartbeat
|
||||
|
||||
Smart Heartbeat 负责按计划或条件主动检查范围内信息,并生成可监督的建议或任务。
|
||||
Smart Heartbeat 已提供按计划或手动执行的有界回顾,生成报告及后续建议。条件或事件唤醒仍需后续设计。
|
||||
|
||||
2026-09-16 确定的目标定位:现有入口计划升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用内部提供工作回顾和故事线图谱。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md)。本轮仅更新文档,现有计划、范围与执行能力未改变,不能将 Agent 定位解释为已有自主工具执行能力。
|
||||
|
||||
## 文档导航
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 故事线图谱(Story Graph)
|
||||
|
||||
这是[监督者应用](../conversation-supervision/supervisor-prd.md)的核心视图。目标对象与交互由[产品设计](./prd.md)定义,用户验收见[User Stories](../conversation-supervision/user-stories.md)。时间事件、知识实体与阶段总结在目标设计中分别建模;下述演示仍使用阶段节点。
|
||||
|
||||
[打开交互演示](./demo.html)。这是监督者故事图谱的独立设计原型,使用模拟数据,尚未接入产品或笔记存储。
|
||||
|
||||
`demo.html`、`demo.css`、`demo.js` 共同负责可交互的界面草案:时间线选择、阶段回放、跨时间线关联、节点总结和来源展开。直接在浏览器中打开 HTML 即可使用,无需安装依赖。
|
||||
|
||||
当前设计假设:每个圆代表一条时间线,实心节点代表阶段总结,实线表示同一时间线的先后关系,虚线表示不同时间线间的知识关联。节点位置为示意布局,尚未定义集中度算法,不代表效率或产出。聚合知识以预置示例演示,不调用模型。
|
||||
|
||||
新增平铺视图(默认):时间轴沿画布外围逆时针环绕,从顶部 09.01 经左侧 09.07、底部到右侧 09.14,以箭头表示继续推进。知识节点平铺在环内,外圈时间点与对应阶段节点直接连接,选择节点时高亮其时间连线。工具栏可切回圆形视图,保留所选阶段、时间线与节点。当前仍使用阶段节点示例,同一知识实体在多个时间点发生修订的合并展示尚未实现。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Story Graph · GoodBuddy 监督者</title>
|
||||
<link rel="stylesheet" href="demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="appbar"><div class="brand"><span class="brandmark">G</span> GoodBuddy <span class="separator">/</span> 监督者</div><div class="top-actions"><span class="badge">交互原型 · 模拟数据</span><button id="theme" aria-label="切换深浅主题">切换主题</button></div></header>
|
||||
<main>
|
||||
<header class="page-heading"><div><div class="eyebrow">STORY GRAPH</div><h1>故事图谱</h1><p>沿着工作的来路,看见知识之间的联系。</p></div><div class="scope">范围:GoodBuddy 项目<br><span>2026.09.01 — 09.14</span></div></header>
|
||||
<div class="workspace">
|
||||
<aside class="timelines"><div class="section-heading"><h2>时间线</h2><span>03</span></div><p class="muted">选择一条故事,查看它的演变。</p><div id="timeline-list"></div><div class="sidebar-note"><span class="eyebrow">如何读图</span><p>一个圆,一条时间线。<br>圆内节点是阶段总结,<br>圆间虚线是知识关联。</p><p class="muted">分布仅作示意。集中度不等于效率或产出。</p></div></aside>
|
||||
<section class="graph-section" aria-label="故事图谱画布"><div class="graph-toolbar"><div><strong id="graph-title">全部故事</strong><span id="node-count" class="muted"></span></div><label class="view-picker">视图 <select id="view" aria-label="图谱视图"><option value="flat">平铺 · 环绕时间轴</option><option value="circles">圆形 · 独立故事</option></select></label><label class="check"><input id="relations" type="checkbox" checked> 跨线关联</label></div>
|
||||
<div class="canvas"><svg id="graph" viewBox="0 0 860 620" role="group" aria-label="可交互故事图谱,使用 Tab 选择阶段节点"></svg><div class="canvas-caption"><span class="live-dot"></span> 阶段总结构成故事,关联让知识相遇</div></div>
|
||||
<div class="graph-legend"><span><i class="dot"></i>阶段总结</span><span><i class="line"></i>时间推进</span><span><i class="line dashed"></i>知识关联</span><button id="reset">显示全部时间线</button></div>
|
||||
<div class="playback"><div class="playback-heading"><strong>故事如何长出来</strong><output id="date-label" for="stage">09.14 · 知识汇聚</output></div><input id="stage" type="range" min="0" max="2" value="2" step="1" aria-label="回放阶段"><div class="range-labels"><span>09.01 · 起点</span><span>09.07 · 探索</span><span>09.14 · 汇聚</span></div></div>
|
||||
</section>
|
||||
<aside class="inspector" aria-label="阶段详情"><div class="section-heading"><h2>故事详情</h2><span class="badge">可追溯</span></div><div id="detail" aria-live="polite"></div></aside>
|
||||
</div>
|
||||
<section class="insights"><div><div class="eyebrow">ACROSS STORIES</div><h2>从不同故事中,找到同一个答案</h2><p>3 条时间线中,有一条共同的知识线索。</p></div><button id="insight" class="insight-card"><span class="insight-icon">↗</span><span><strong>知识需要保留来路</strong><small>故事图谱 × 个人知识库 × 记忆架构</small></span><span>查看聚合 →</span></button></section>
|
||||
<footer>设计讨论稿 · 所有总结、关联和来源均为模拟示例;未读取或修改真实会话。</footer>
|
||||
</main>
|
||||
<script src="demo.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
/* global document */
|
||||
const stories = [
|
||||
{ id: 'graph', title: '监督者 · 故事图谱', color: 'var(--accent)', cx: 270, cy: 230, r: 172, description: '从工作过程到可追溯的故事' },
|
||||
{ id: 'knowledge', title: '个人知识库', color: 'var(--success)', cx: 625, cy: 260, r: 146, description: '从学习记录到可复用的知识' },
|
||||
{ id: 'memory', title: '记忆架构', color: 'var(--warning)', cx: 451, cy: 463, r: 118, description: '从原始数据到分层理解' }
|
||||
];
|
||||
const nodes = [
|
||||
{ id: 'g0', story: 'graph', stage: 0, x: 160, y: 205, label: '观察工作过程', title: '让工作过程可以回看', copy: '监督者按时间记录工作的推进。我们希望在记录之上保留阶段性的理解,帮助用户重新进入一段未完成的工作。', source: '监督者功能讨论', quote: '过一周再回来,我需要知道之前为什么这样做,而不只是看到最后的结果。' },
|
||||
{ id: 'g1', story: 'graph', stage: 1, x: 261, y: 260, label: '阶段总结', title: '把连续记录整理成阶段', copy: '每个阶段保留讨论的问题、形成的判断和仍未解决的事项。阶段节点沿时间连接,组成一条能够继续展开的故事。', source: '阶段总结交互草案', quote: '以一个阶段为单位总结。保留关键决定,也保留没有走通的尝试和原始记录。' },
|
||||
{ id: 'g2', story: 'graph', stage: 2, x: 326, y: 215, label: '保留知识来路', title: '总结之后,仍能找到来路', copy: '故事图谱把阶段总结和原始记录连接起来。跨时间线聚合出的知识,也应保留每条贡献来源,方便核对结论、理解语境。', source: 'Story Graph 方案讨论', quote: '阶段总结与聚合知识应保留来源,支持追溯原始记录。', related: 'k2', reason: '共同要求:聚合知识仍可回到原始记录' },
|
||||
{ id: 'k0', story: 'knowledge', stage: 0, x: 706, y: 213, label: '学习记录', title: '记录学习中的问题', copy: '学习时产生的笔记与问答散落在不同会话中。先保留它们的上下文,再考虑如何归入个人知识库。', source: '学习知识图谱讨论', quote: '学习时的提问和笔记可以放在一起,之后能够继续补充。' },
|
||||
{ id: 'k1', story: 'knowledge', stage: 1, x: 635, y: 304, label: '知识关联', title: '把同一个问题的线索连起来', copy: '不同笔记可能围绕同一个问题。关联需要说明它们共有的主题,以及各自提供了什么补充。', source: '个人知识库设计记录', quote: '关联不只是两段内容相似,还要说明这条联系为什么有用。' },
|
||||
{ id: 'k2', story: 'knowledge', stage: 2, x: 562, y: 240, label: '来源追溯', title: '知识条目携带出处', copy: '知识库中的归纳附带原始笔记和讨论位置。用户可以从结论返回上下文,判断它是否适用于当前问题。', source: '知识条目来源设计', quote: '每条归纳都应该能够展开出处,看到原话和产生结论时的上下文。', related: 'g2', reason: '共同要求:聚合知识仍可回到原始记录' },
|
||||
{ id: 'm0', story: 'memory', stage: 0, x: 380, y: 475, label: '原始数据', title: '从真实记录开始', copy: '原始会话、笔记和执行结果构成记忆的基础。上层归纳需要能指向这些记录。', source: '记忆基础讨论', quote: '先有记录,再有总结;总结不能取代原始记录。' },
|
||||
{ id: 'm1', story: 'memory', stage: 1, x: 465, y: 504, label: 'DIKW 分层', title: '区分记录与理解的层次', copy: '尝试按数据、信息、知识、智慧组织记忆。图谱中的阶段总结与知识聚合,可能成为这些层次之间的连接。', source: 'DIKW 设计草案', quote: '数据、信息、知识、智慧应当分层呈现,让用户知道当前看到的是什么。' },
|
||||
{ id: 'm2', story: 'memory', stage: 2, x: 478, y: 438, label: '可解释的归纳', title: '让每层归纳都能被解释', copy: '从数据到知识的每一步,都应说明归纳依据。阶段总结可以连接过程记录,聚合知识可以连接多个阶段。', source: '记忆分层讨论', quote: '一条知识来自哪些信息、信息又来自哪些数据,需要能够顺着关系找回去。', related: 'g2', reason: '共同要求:上层归纳保留底层依据' }
|
||||
];
|
||||
const dates = ['09.01 · 故事起点', '09.07 · 持续探索', '09.14 · 知识汇聚'];
|
||||
let activeStory = null;
|
||||
let selected = 'g2';
|
||||
let stage = 2;
|
||||
const graph = document.querySelector('#graph');
|
||||
const detail = document.querySelector('#detail');
|
||||
|
||||
function showDetail() {
|
||||
const node = nodes.find(item => item.id === selected);
|
||||
const story = stories.find(item => item.id === node.story);
|
||||
const related = node.related && nodes.find(item => item.id === node.related);
|
||||
detail.innerHTML = `<div class="detail-intro"><div class="detail-kicker">${story.title} / 阶段 ${node.stage + 1}</div><h2 class="detail-title">${node.title}</h2><p class="detail-copy">${node.copy}</p></div>
|
||||
<div class="detail-block"><h3>跨时间线关联</h3>${related && related.stage <= stage ? `<button class="relation-link" data-target="${related.id}">${related.title} ↗<small>${node.reason}</small></button>` : '<p class="muted">此阶段尚未建立跨线关联。</p>'}</div>
|
||||
<div class="detail-block"><h3>来源记录 · 1 条</h3><details class="source"><summary>${node.source}</summary><small>模拟会话 / 2026.${dates[node.stage].slice(0, 5)} / 消息 #${node.stage + 1}</small><p>“${node.quote}”</p></details><small>演示来源,可展开查看模拟原文。</small></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.querySelector('#timeline-list').innerHTML = stories.map(story => `<button class="timeline" data-story="${story.id}" aria-pressed="${activeStory === story.id}" style="--story-color:${story.color}"><span class="timeline-top"><i class="timeline-color"></i><span>0${stage + 1} 个阶段</span></span><strong>${story.title}</strong><small>${story.description}</small></button>`).join('');
|
||||
document.querySelector('#graph-title').textContent = activeStory ? stories.find(item => item.id === activeStory).title : '全部故事';
|
||||
document.querySelector('#node-count').textContent = ` / ${(stage + 1) * (activeStory ? 1 : 3)} 个阶段`;
|
||||
document.querySelector('#date-label').textContent = dates[stage];
|
||||
const showRelations = document.querySelector('#relations').checked;
|
||||
const flat = document.querySelector('#view').value === 'flat';
|
||||
document.querySelector('.sidebar-note p').innerHTML = flat ? '时间沿外圈逆时针推进。<br>内部平铺知识节点,<br>时间点直接连接对应节点。' : '一个圆,一条时间线。<br>圆内节点是阶段总结,<br>圆间虚线是知识关联。';
|
||||
document.querySelector('.canvas-caption').innerHTML = `<span class="live-dot"></span> ${flat ? '外圈时间逆时针推进 · 点击节点高亮时间连线' : '阶段总结构成故事,关联让知识相遇'}`;
|
||||
const plotted = nodes.map(node => flat ? { ...node, x: [280, 430, 580][stories.findIndex(story => story.id === node.story)], y: [210, 330, 450][node.stage] } : node);
|
||||
let markup = stories.map(story => `<g style="--story-color:${story.color}" class="${activeStory && activeStory !== story.id ? 'dim' : ''}"><circle class="orbit" cx="${story.cx}" cy="${story.cy}" r="${story.r}"/><circle class="orbit-inner" cx="${story.cx}" cy="${story.cy}" r="${story.r * .62}"/><text class="orbit-label" x="${story.cx}" y="${story.cy - story.r + 36}" text-anchor="middle">${story.title}</text><text class="orbit-caption" x="${story.cx}" y="${story.cy - story.r + 55}" text-anchor="middle">${stage + 1} 个阶段 · ${story.id === 'graph' ? '持续收敛' : story.id === 'knowledge' ? '多向探索' : '逐步归纳'}</text><path class="story-path" d="${nodes.filter(node => node.story === story.id && node.stage <= stage).map((node, index) => `${index ? 'L' : 'M'}${node.x},${node.y}`).join(' ')}"/></g>`).join('');
|
||||
if (showRelations && stage === 2) markup += '<path class="cross-path" d="M326 215 Q439 153 562 240 M326 215 Q366 362 478 438 M562 240 Q565 365 478 438"/><text class="bridge-label" x="413" y="188">来源追溯</text><text class="bridge-label" x="372" y="367">归纳依据</text>';
|
||||
if (flat) {
|
||||
const anchors = [{ x: 430, y: 65 }, { x: 95, y: 310 }, { x: 765, y: 310 }];
|
||||
markup = `<defs><marker id="time-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" fill="var(--accent)"/></marker></defs>
|
||||
<ellipse class="time-boundary" cx="430" cy="310" rx="335" ry="245"/>
|
||||
<path class="time-ring" marker-end="url(#time-arrow)" d="M430 65 A335 245 0 0 0 95 310 A335 245 0 0 0 430 555 A335 245 0 0 0 765 310 A335 245 0 0 0 560 84"/>
|
||||
<text class="orbit-caption" x="430" y="30" text-anchor="middle">起点 · 逆时针沿外圈阅读 ↶</text>`;
|
||||
markup += plotted.filter(node => node.stage <= stage).map(node => {
|
||||
const anchor = anchors[node.stage];
|
||||
return `<path class="time-link ${node.id === selected ? 'active' : ''} ${activeStory && activeStory !== node.story ? 'dim' : ''}" d="M${anchor.x} ${anchor.y} Q${anchor.x} ${node.y} ${node.x} ${node.y}"/>`;
|
||||
}).join('');
|
||||
markup += stories.map(story => `<g style="--story-color:${story.color}" class="${activeStory && activeStory !== story.id ? 'dim' : ''}"><path class="story-path" d="${plotted.filter(node => node.story === story.id && node.stage <= stage).map((node, index) => `${index ? 'L' : 'M'}${node.x},${node.y}`).join(' ')}"/><text class="orbit-caption" text-anchor="middle" x="${plotted.find(node => node.story === story.id).x}" y="165">${story.title}</text></g>`).join('');
|
||||
if (showRelations && stage === 2) markup += '<path class="cross-path" d="M280 450 Q355 407 430 450 M280 450 Q430 515 580 450 M430 450 Q505 407 580 450"/>';
|
||||
markup += anchors.map((anchor, index) => `<g class="time-point ${index > stage ? 'dim' : ''}"><circle cx="${anchor.x}" cy="${anchor.y}" r="7"/><text x="${anchor.x}" y="${anchor.y - 20}" text-anchor="middle">${dates[index].slice(0, 5)}</text><text class="orbit-caption" x="${anchor.x}" y="${anchor.y + 28}" text-anchor="middle">${['首次提出', '持续补充', '形成归纳'][index]}</text></g>`).join('');
|
||||
}
|
||||
markup += plotted.filter(node => node.stage <= stage).map(node => {
|
||||
const story = stories.find(item => item.id === node.story);
|
||||
return `<g class="node ${node.id === selected ? 'selected' : ''} ${activeStory && activeStory !== node.story ? 'dim' : ''}" style="--story-color:${story.color}" transform="translate(${node.x} ${node.y})" tabindex="0" role="button" aria-label="${story.title}:${node.title}" aria-pressed="${node.id === selected}" data-node="${node.id}"><circle class="halo" r="22"/><circle class="core" r="${node.stage === 2 ? 10 : 7}"/><text text-anchor="middle" y="34">${node.label}</text></g>`;
|
||||
}).join('');
|
||||
graph.innerHTML = markup;
|
||||
showDetail();
|
||||
}
|
||||
|
||||
function selectNode(id) {
|
||||
selected = id;
|
||||
render();
|
||||
}
|
||||
document.querySelector('#timeline-list').addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-story]');
|
||||
if (!button) return;
|
||||
activeStory = activeStory === button.dataset.story ? null : button.dataset.story;
|
||||
if (activeStory) selected = nodes.find(node => node.story === activeStory && node.stage === stage).id;
|
||||
render();
|
||||
});
|
||||
graph.addEventListener('click', event => {
|
||||
const node = event.target.closest('[data-node]');
|
||||
if (node) selectNode(node.dataset.node);
|
||||
});
|
||||
graph.addEventListener('keydown', event => {
|
||||
const node = event.target.closest('[data-node]');
|
||||
if (node && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
const id = node.dataset.node;
|
||||
selectNode(id);
|
||||
graph.querySelector(`[data-node="${id}"]`).focus();
|
||||
}
|
||||
});
|
||||
detail.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-target]');
|
||||
if (!button) return;
|
||||
activeStory = null;
|
||||
selectNode(button.dataset.target);
|
||||
});
|
||||
document.querySelector('#relations').addEventListener('change', render);
|
||||
document.querySelector('#view').addEventListener('change', render);
|
||||
document.querySelector('#reset').addEventListener('click', () => { activeStory = null; render(); });
|
||||
document.querySelector('#stage').addEventListener('input', event => {
|
||||
stage = Number(event.target.value);
|
||||
const story = activeStory || nodes.find(node => node.id === selected).story;
|
||||
selected = nodes.find(node => node.story === story && node.stage === stage).id;
|
||||
render();
|
||||
});
|
||||
document.querySelector('#theme').addEventListener('click', () => {
|
||||
document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
});
|
||||
document.querySelector('#insight').addEventListener('click', () => {
|
||||
stage = 2;
|
||||
activeStory = null;
|
||||
document.querySelector('#stage').value = '2';
|
||||
document.querySelector('#relations').checked = true;
|
||||
render();
|
||||
detail.innerHTML = `<div class="detail-intro"><div class="detail-kicker">跨时间线聚合 / 示例</div><h2 class="detail-title">知识需要保留来路</h2><p class="detail-copy">三个故事指向同一个设计要求:不论是阶段总结、知识条目,还是分层记忆,归纳结果都需要连接原始依据。</p></div><div class="detail-block"><h3>来自 3 条时间线</h3>${['g2', 'k2', 'm2'].map(id => { const node = nodes.find(item => item.id === id); return `<button class="relation-link" data-target="${id}">${node.title} ↗<small>${stories.find(item => item.id === node.story).title} · 点击查看阶段与来源</small></button>`; }).join('')}</div><div class="detail-block"><h3>聚合说明</h3><p class="muted">以上为预置的聚合示例。实际功能需要结合来源生成关联依据,并允许用户核对。</p></div>`;
|
||||
document.querySelector('.inspector').scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
render();
|
||||
@@ -0,0 +1,39 @@
|
||||
# 故事线图谱产品与交互设计
|
||||
|
||||
日期:2026-09-16。状态:目标设计;当前 Demo 仅验证部分布局与交互。
|
||||
|
||||
图谱属于[监督者应用](../conversation-supervision/supervisor-prd.md),负责展示工作过程与知识演变。应用入口、心跳和知识库写入边界由该产品设计定义。
|
||||
|
||||
## 对象
|
||||
|
||||
对象之间的关系与监督者数据流见[逻辑设计](../conversation-supervision/logic-design.md),本文负责对象含义及用户操作要求。
|
||||
|
||||
FR-G1:外圈时间轴承载事件,内部节点表示持续积累的知识实体,阶段总结解释一个时间区间。同一知识实体可连接多个时间事件,分别表示提出、补充、验证或修订。新增方案不自动意味着旧方案已失效。
|
||||
|
||||
| 对象 | 内容 |
|
||||
| --- | --- |
|
||||
| 故事线 | 围绕某个工作主题形成的事件与知识集合 |
|
||||
| 时间事件 | 发生时间、所属故事、来源及影响的知识实体 |
|
||||
| 知识实体 | 当前描述、关联来源、演变记录与确认状态 |
|
||||
| 阶段总结 | 指定范围内的决定、变化和未解决事项 |
|
||||
| 关系 | 两端对象、关系类型、依据与确认状态 |
|
||||
|
||||
## 环绕时间轴
|
||||
|
||||
FR-G2:默认平铺视图将时间轴放在外围,逆时针围绕内部知识实体。起点、终点、方向和当前时间范围持续可见,外圈保留缺口,避免暗示时间循环。日期密集时聚合事件数量,展开后选择具体事件;时间位置采用真实比例还是等距事件布局需在下一轮原型中确定并明确标注。
|
||||
|
||||
选择事件后,高亮其影响的知识实体,右侧显示事件摘要和原始记录入口。外围时间点与内部实体使用实际连线;未选中的关系降低显著度。支持缩放、平移和聚焦,新增内容与回放尽量保留实体位置。
|
||||
|
||||
FR-G3:选择知识实体时,高亮对应的多个时间点与连线,标明提出、补充、验证或修订。右侧优先显示当前理解,再列演变和来源。历史回放下显示截至所选时刻的理解。
|
||||
|
||||
FR-G4:选择关系后,展示类型、关联理由及两端依据。跨故事关系与时间到实体的关系分别说明。用户可以确认、修改类型或移除关系;自动发现的关联标注待核对。
|
||||
|
||||
FR-G5:支持选择时间区间查看阶段总结,通过底部控制回放已发生事件及当时已知内容。圆形视图保留,用于观察独立故事聚合;视图切换保留范围、选择与回放时刻。集中度只描述关联分布,不等同于效率或产出,具体计算方式尚待设计。
|
||||
|
||||
FR-G6:默认展示主要实体及阶段事件,选中后展开一层关联,进一步展开由用户触发。提供文字列表完成与图形等价的对象选择、关系查看和来源跳转,遵循[监督者 UI 设计](../conversation-supervision/ui-design.md)的键盘与窄屏要求。
|
||||
|
||||
## 原型边界与下一轮验证
|
||||
|
||||
当前 Demo 使用阶段节点、固定坐标和预置关联,支持环绕时间轴、视图切换、回放、来源展开及模拟聚合;未实现真实实体合并、生成、历史版本、知识库接入或继续讨论。
|
||||
|
||||
下一轮场景:用户查看近期总结,选择“故事图谱布局”实体,看到三次讨论分别提出圆形、增加平铺、改为外圈逆时针时间轴,再查看来源并预览继续讨论的上下文。该场景应验证同一实体的多时点连接、事件和关系详情、阶段选择及上下文预览。验收映射见[User Stories](../conversation-supervision/user-stories.md) US-S04 至 US-S13。
|
||||
@@ -9,13 +9,22 @@
|
||||
3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。
|
||||
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
|
||||
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
|
||||
6. [技术设计](./technical-design.md):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。
|
||||
|
||||
正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
[当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。
|
||||
配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。
|
||||
项目任务标题右侧依次为 `+` 新建按钮与筛选图标,状态筛选默认收起;应用筛选后图标高亮并显示当前筛选条件。
|
||||
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。
|
||||
|
||||
先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
|
||||
若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [本机专用推理架构与任务控制评估](../../architecture/local-inference-task-control.md)记录 ASR、OCR、embedding 的任务与服务状态、执行所有权、取消、超时、清理及观测待办;推理请求不自动等同于本目录的产品 Task,候选迁移尚未采用。
|
||||
- [直连模型 Agent 能力](../direct-model-agent/README.md) 使用本目录定义的 Subagent 与
|
||||
Job/Subjob 语义,为直连模型提供单层编程委派,但不重新定义产品对象层级。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --success: #15725d; --warning: #865200;
|
||||
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
}
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --success: #73d9bb; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: var(--surface-subtle); color: var(--text-primary); font: 13px/1.65 var(--font-family-ui); }
|
||||
button, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-control); padding: 8px 12px; background: transparent; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
select { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); }
|
||||
h1, h2, h3, p { margin: 0; } h1 { font-size: 24px; font-weight: 600; line-height: 1.4; } h2 { font-size: 14px; } h3 { font-size: 13px; }
|
||||
.demo-bar, .demo-footer { max-width: 1500px; margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.demo-bar { margin-bottom: 16px; }.demo-bar strong { font-weight: 600; }.demo-bar span { margin-left: 16px; color: var(--text-secondary); font-size: 12px; }
|
||||
#theme { border-color: var(--border-default); }
|
||||
.workspace { max-width: 1500px; margin: auto; display: grid; grid-template-columns: 240px minmax(0, 1fr) 328px; height: calc(100dvh - 132px); min-height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { display: flex; flex-direction: column; padding: 24px 16px; background: var(--surface-canvas); border-right: 1px solid var(--border-default); overflow: auto; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; font-size: 16px; }.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-control); color: var(--surface-raised); background: var(--text-primary); font-weight: 700; }
|
||||
label, .eyebrow { font-size: 11px; color: var(--text-secondary); }label { display: block; margin-bottom: 8px; }
|
||||
.muted, .metadata, .sidebar-note, .task-footnote { color: var(--text-secondary); font-size: 12px; }
|
||||
#project-meta { margin: 8px 0 32px; }.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.section-heading > span { color: var(--text-secondary); font-size: 11px; }
|
||||
#sessions { margin-top: 12px; display: grid; gap: 8px; }#sessions button { text-align: left; width: 100%; padding: 12px; }#sessions button[aria-current="true"] { color: var(--accent); background: var(--surface-raised); border-color: var(--border-default); }
|
||||
#sessions small { display: block; color: var(--text-secondary); margin-top: 4px; font-size: 11px; }.sidebar-note { margin-top: auto; padding-top: 32px; line-height: 1.9; }
|
||||
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }.conversation-header { padding: 24px 32px; border-bottom: 1px solid var(--border-default); }.eyebrow { margin-bottom: 8px; }.metadata { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 12px; }
|
||||
.messages { flex: 1; min-height: 0; overflow: auto; padding: 32px; }.message { max-width: 720px; margin: 0 auto 32px; }.message-author { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 12px; font-weight: 600; }.message-author time { margin-left: auto; color: var(--text-secondary); font-size: 11px; font-weight: 400; }.message p { font-size: 14px; line-height: 1.9; white-space: pre-line; }.message.user p { background: var(--surface-subtle); padding: 16px; border-radius: var(--radius-card); }
|
||||
.linked-task { display: block; margin-top: 16px; text-align: left; width: 100%; border-color: var(--border-default); color: var(--accent); }.linked-task small { display: block; color: var(--text-secondary); margin-top: 4px; }
|
||||
.composer { padding: 16px 32px 24px; }.composer-box { padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-card); color: var(--text-secondary); }.composer-box div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 24px; font-size: 11px; }.composer > p { margin-top: 8px; font-size: 11px; color: var(--text-secondary); }
|
||||
.tasks { border-left: 1px solid var(--border-default); background: var(--surface-subtle); padding: 24px 16px; overflow: auto; }.tasks header { margin-bottom: 24px; }.tasks header p { margin-top: 4px; }.badge { border: 1px solid var(--border-default); border-radius: var(--radius-control); padding: 0 8px; }.scope-label { margin: 8px 0 16px; color: var(--text-secondary); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; gap: 4px; border-bottom: 1px solid var(--border-default); padding-bottom: 12px; margin-bottom: 16px; }.filters button { padding: 4px 12px; font-size: 12px; }.filters [aria-pressed="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent); }
|
||||
.task-card { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 16px; margin-bottom: 12px; }.task-card .status { flex-shrink: 0; font-size: 11px; color: var(--accent); }.task-card .status.done { color: var(--success); }.task-card p { color: var(--text-secondary); font-size: 11px; margin-top: 8px; }.empty { padding: 24px 8px; color: var(--text-secondary); text-align: center; }.demo-footer { color: var(--text-secondary); margin-top: 12px; font-size: 11px; }
|
||||
.session-stats { padding-bottom: 24px; border-bottom: 1px solid var(--border-default); }.session-stats h3 { font-weight: 500; }.stat-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 24px 0 12px; gap: 16px; }.stat-grid dt { font-size: 12px; color: var(--text-secondary); }.stat-grid dd { margin: 4px 0 0; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }.session-stats details { margin-top: 16px; font-size: 11px; color: var(--text-secondary); }.session-stats summary { cursor: pointer; }.session-stats details p { margin-top: 8px; }.task-heading { margin: 24px 0 12px; }.task-duration { display: flex; justify-content: space-between; margin-top: 16px; font-size: 12px; }.task-duration span { color: var(--text-secondary); }.task-duration strong { font-weight: 500; }
|
||||
.task-session { padding: 4px 0; margin-top: 4px; text-align: left; color: var(--accent); font-size: 11px; }.task-actions { display: flex; gap: 8px; margin-top: 12px; }.task-actions button { border-color: var(--border-default); padding: 4px 12px; font-size: 12px; }
|
||||
.task-toolbar { display: flex; gap: 4px; }.task-toolbar button { display: grid; place-items: center; width: 32px; height: 32px; padding: 4px; color: var(--text-secondary); }#new-task { font-size: 22px; line-height: 1; }.task-toolbar svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }.task-toolbar .is-active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); }#task-filter { margin-bottom: 16px; }#task-count { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
|
||||
#task-form { padding: 16px; margin-bottom: 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); }#task-form label { margin-top: 12px; }#task-form textarea, #task-form input { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); font: inherit; }#task-form textarea { resize: vertical; }#task-form p { margin-top: 12px; }#task-form button[type="submit"] { color: var(--accent); border-color: var(--accent); }textarea:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }.task-card h3, .task-session, #sessions button { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .workspace { grid-template-columns: 200px minmax(0, 1fr) 280px; }.conversation-header, .messages { padding: 24px; }.composer { padding: 16px 24px; } }
|
||||
@media (max-width: 850px) { body { padding: 16px; }.workspace { height: auto; grid-template-columns: 200px minmax(0, 1fr); }.tasks { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border-default); }#task-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.task-card { margin: 0; }.messages { min-height: 280px; }.demo-footer > span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { body { padding: 8px; }.demo-bar { align-items: start; }.demo-bar span { display: block; margin-left: 0; }.workspace { display: flex; flex-direction: column; }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 16px; }.brand, .sidebar-note { display: none; }#project-meta { margin: 8px 0 16px; }#sessions { grid-template-columns: repeat(2, minmax(0, 1fr)); }#sessions button { padding: 8px; }.conversation-header, .messages { padding: 24px 16px; }.composer { padding: 16px; }#task-list { grid-template-columns: 1fr; }.demo-footer { padding: 0 8px; } }
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 当前会话统计</title>
|
||||
<link rel="stylesheet" href="hierarchy-demo.css">
|
||||
<script src="hierarchy-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="demo-bar"><div><strong>GoodBuddy / 会话统计 Demo</strong><span>演示数据 · 时间为固定样例</span></div><button id="theme" aria-pressed="false">深色模式</button></header>
|
||||
<div class="workspace">
|
||||
<aside class="sidebar" aria-label="项目与会话">
|
||||
<div class="brand"><span class="brand-mark">G</span><strong>GoodBuddy</strong></div>
|
||||
<div class="eyebrow">当前项目</div>
|
||||
<strong>GoodBuddy 产品设计</strong>
|
||||
<p id="project-meta" class="muted">工作目录 · goodbuddy</p>
|
||||
<div class="section-heading"><h2>会话</h2><span id="session-count"></span></div>
|
||||
<nav id="sessions" aria-label="当前项目的会话"></nav>
|
||||
</aside>
|
||||
<main>
|
||||
<header class="conversation-header"><div class="eyebrow">GoodBuddy 产品设计</div><h1 id="conversation-title"></h1></header>
|
||||
<section class="messages" id="messages" aria-label="会话内容"></section>
|
||||
<div class="composer"><div class="composer-box">继续交流,或交代下一件事…<div><span>Execute · OpenCode</span><span>输入区仅作布局展示</span></div></div></div>
|
||||
</main>
|
||||
<aside class="tasks" aria-label="任务中心">
|
||||
<header><h2>任务中心</h2><p class="muted">当前项目 · GoodBuddy 产品设计</p></header>
|
||||
<section class="session-stats" aria-label="当前会话统计" aria-live="polite">
|
||||
<div class="eyebrow">当前会话</div><h3 id="stats-title"></h3>
|
||||
<dl class="stat-grid"><div><dt>Agent 累计回复时长</dt><dd id="duration"></dd></div><div><dt>消息数</dt><dd id="message-count"></dd></div></dl>
|
||||
<p id="message-breakdown" class="muted"></p>
|
||||
<details><summary>时间说明</summary><p id="time-description"></p><p>累计每次 Agent 回复的结束时间减去开始时间,不包含回复之间的空闲时间。任务用时单独展示,包含任务中的等待时间。</p></details>
|
||||
</section>
|
||||
<div class="section-heading task-heading"><h2>项目任务 <span id="task-count"></span></h2><div class="task-toolbar"><button id="new-task" aria-label="新建任务" title="新建任务" aria-expanded="false" aria-controls="task-form">+</button><button id="filter-toggle" aria-label="筛选任务" title="筛选任务" aria-expanded="false" aria-controls="task-filter"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16l-6 7v6l-4 2v-8z"/></svg></button></div></div>
|
||||
<form id="task-form" hidden>
|
||||
<h3>新建任务</h3>
|
||||
<label for="task-content">任务内容</label><textarea id="task-content" rows="3" required maxlength="500" placeholder="描述要完成的工作"></textarea>
|
||||
<label for="task-conversation">关联会话</label><select id="task-conversation"></select>
|
||||
<label for="task-timing">执行时间</label><select id="task-timing"><option value="now">立即执行</option><option value="scheduled">定时执行</option></select>
|
||||
<div id="schedule-field" hidden><label for="task-date">计划时间</label><input id="task-date" type="datetime-local"></div>
|
||||
<p class="muted">仅在此页面模拟创建,刷新后恢复样例。</p>
|
||||
<div class="task-actions"><button type="submit">创建任务</button><button id="close-form" type="button">取消</button></div>
|
||||
</form>
|
||||
<div id="task-filter" hidden><label for="task-status">任务状态</label>
|
||||
<select id="task-status"><option value="all">全部状态</option><option value="进行中">进行中</option><option value="待执行">待执行</option><option value="已暂停">已暂停</option><option value="ended">已结束</option></select></div>
|
||||
<p id="active-filter" class="scope-label" hidden></p>
|
||||
<div id="task-list" aria-live="polite"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="demo-footer"><span>切换会话更新统计;项目任务保持可见,可暂停、继续或取消。</span><span>范围固定为当前项目</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
const conversations = [
|
||||
{ id: 'layout', title: '会话统计信息设计', userCount: 12, assistantCount: 12, start: '14:05', end: '14:32', replies: [[0, 12], [50, 74], [120, 136], [220, 240], [300, 332], [440, 458], [590, 615], [720, 750], [890, 930], [1100, 1145], [1350, 1378], [1580, 1620]], user: '任务中心可以增加当前会话、任务的时长和消息数,信息可能没多少。', reply: '右侧累计每次 Agent 从开始回复到结束回复的时间,不计两次回复之间的空闲。下方管理当前项目的全部任务,也可以直接新建任务。\n\n切换会话时只更新会话统计,项目任务列表保留。', tasks: [
|
||||
{ title: '制作统计布局 Demo', status: '进行中', duration: '6 分钟', time: '14:26 开始', active: true },
|
||||
{ title: '核对统计字段', status: '已完成', duration: '3 分钟', time: '14:21 至 14:24', active: false }
|
||||
] },
|
||||
{ id: 'review', title: '导航方案评审', userCount: 5, assistantCount: 7, start: '10:18', end: '11:46', replies: [[0, 30], [300, 350], [700, 740], [1400, 1470], [2200, 2260], [3500, 3580], [5230, 5280]], user: '整理一下导航方案中的问题,形成一份评审清单。', reply: '评审清单已整理完成,包括项目入口、会话导航和任务状态展示。', tasks: [
|
||||
{ title: '整理导航评审清单', status: '已完成', duration: '8 分钟', time: '11:38 至 11:46', active: false },
|
||||
{ title: '补充导航交互说明', status: '已暂停', duration: '2 分钟', time: '11:48 开始', active: true }
|
||||
] },
|
||||
{ id: 'ideas', title: '随手聊聊产品想法', userCount: 3, assistantCount: 3, start: '16:20', end: '16:38', replies: [[0, 8], [400, 411], [1071, 1080]], user: '消息数量应该算条数,还是算对话轮数?', reply: '显示消息条数更直观。用户发言和助手回复分别计数,工具调用不单独算成聊天消息。', tasks: [] }
|
||||
]
|
||||
let conversationId = 'layout'
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
|
||||
function render() {
|
||||
const conversation = conversations.find((item) => item.id === conversationId)
|
||||
$('session-count').textContent = `${conversations.length} 个`
|
||||
$('sessions').innerHTML = conversations.map((item) => `<button data-conversation="${item.id}" aria-current="${item.id === conversationId}">${escape(item.title)}<small>${item.userCount + item.assistantCount} 条消息</small></button>`).join('')
|
||||
$('conversation-title').textContent = conversation.title
|
||||
$('stats-title').textContent = conversation.title
|
||||
// Demo intervals are reply start/end offsets in seconds, including idle gaps.
|
||||
const replySeconds = conversation.replies.reduce((sum, [start, end]) => sum + end - start, 0)
|
||||
$('duration').textContent = replySeconds >= 60 ? `${Math.floor(replySeconds / 60)} 分 ${replySeconds % 60} 秒` : `${replySeconds} 秒`
|
||||
$('message-count').textContent = `${conversation.userCount + conversation.assistantCount} 条`
|
||||
$('message-breakdown').textContent = `你 ${conversation.userCount} 条 · 助手 ${conversation.assistantCount} 条`
|
||||
$('time-description').textContent = `本会话 ${conversation.replies.length} 次已结束回复,累计 ${replySeconds} 秒。演示按每次回复的起止时间计算。`
|
||||
$('messages').innerHTML = conversation.userCount ? `<article class="message user"><div class="message-author">你 <time>${conversation.start}</time></div><p>${escape(conversation.user)}</p></article><article class="message"><div class="message-author">GoodBuddy <time>${conversation.end}</time></div><p>${escape(conversation.reply)}</p></article>` : '<p class="empty">任务已关联此会话,尚无消息。Demo 不执行真实 Agent。</p>'
|
||||
const projectTasks = conversations.flatMap((session) => session.tasks.map((task, index) => ({ task, session, index })))
|
||||
const status = $('task-status').value
|
||||
const filtered = status !== 'all'
|
||||
const filterLabel = $('task-status').selectedOptions[0].textContent
|
||||
$('filter-toggle').classList.toggle('is-active', filtered)
|
||||
$('filter-toggle').setAttribute('aria-label', filtered ? `筛选任务:${filterLabel}` : '筛选任务')
|
||||
$('filter-toggle').title = filtered ? `筛选任务:${filterLabel}` : '筛选任务'
|
||||
$('active-filter').hidden = !filtered
|
||||
$('active-filter').textContent = `筛选:${filterLabel}`
|
||||
const visibleTasks = projectTasks.filter(({ task }) => status === 'all' || (status === 'ended' ? !task.active : task.status === status))
|
||||
$('task-count').textContent = `${visibleTasks.length} / ${projectTasks.length} 项`
|
||||
$('task-list').innerHTML = visibleTasks.length ? visibleTasks.map(({ task, session, index }) => `<article class="task-card" data-session="${session.id}" data-index="${index}"><div class="section-heading"><h3>${escape(task.title)}</h3><span class="status ${task.active ? '' : 'done'}">${task.status}</span></div><button class="task-session" data-open="${session.id}">${session.id === conversationId ? '当前会话' : '会话'} · ${escape(session.title)}</button><div class="task-duration"><span>${task.active ? '已用时' : '总用时'}</span><strong>${task.duration}</strong></div><p>${escape(task.time)}</p>${task.active ? `<div class="task-actions"><button data-action="toggle">${task.status === '已暂停' ? '继续' : '暂停'}</button><button data-action="cancel">取消任务</button></div>` : ''}</article>`).join('') : '<p class="empty">当前项目暂无此状态的任务</p>'
|
||||
}
|
||||
$('task-status').addEventListener('change', render)
|
||||
$('filter-toggle').addEventListener('click', () => {
|
||||
const expanded = $('task-filter').hidden
|
||||
$('task-filter').hidden = !expanded
|
||||
$('filter-toggle').setAttribute('aria-expanded', String(expanded))
|
||||
if (expanded) $('task-status').focus()
|
||||
})
|
||||
$('task-filter').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return
|
||||
$('task-filter').hidden = true
|
||||
$('filter-toggle').setAttribute('aria-expanded', 'false')
|
||||
$('filter-toggle').focus()
|
||||
})
|
||||
$('task-list').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button')
|
||||
if (!button) return
|
||||
if (button.dataset.open) {
|
||||
conversationId = button.dataset.open
|
||||
render()
|
||||
$('conversation-title').tabIndex = -1
|
||||
$('conversation-title').focus()
|
||||
return
|
||||
}
|
||||
const card = button.closest('[data-session]')
|
||||
const task = conversations.find((session) => session.id === card.dataset.session).tasks[Number(card.dataset.index)]
|
||||
if (button.dataset.action === 'cancel') {
|
||||
task.status = '已取消'
|
||||
task.active = false
|
||||
} else {
|
||||
if (task.status === '已暂停') {
|
||||
task.status = task.resumeStatus || '进行中'
|
||||
} else {
|
||||
task.resumeStatus = task.status
|
||||
task.status = '已暂停'
|
||||
}
|
||||
}
|
||||
const sessionId = card.dataset.session
|
||||
const index = card.dataset.index
|
||||
render()
|
||||
const next = $('task-list').querySelector(`[data-session="${sessionId}"][data-index="${index}"] button`)
|
||||
;(next || ($('task-filter').hidden ? $('filter-toggle') : $('task-status'))).focus()
|
||||
})
|
||||
$('sessions').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-conversation]')
|
||||
if (!button) return
|
||||
conversationId = button.dataset.conversation
|
||||
render()
|
||||
$('sessions').querySelector(`[data-conversation="${conversationId}"]`).focus()
|
||||
})
|
||||
$('theme').addEventListener('click', () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark'
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
$('theme').setAttribute('aria-pressed', String(dark))
|
||||
$('theme').textContent = dark ? '浅色模式' : '深色模式'
|
||||
})
|
||||
$('new-task').addEventListener('click', () => {
|
||||
$('task-form').hidden = false
|
||||
$('new-task').setAttribute('aria-expanded', 'true')
|
||||
$('task-conversation').innerHTML = conversations.map((session) => `<option value="${session.id}">${escape(session.title)}</option>`).join('') + '<option value="new">新建会话</option>'
|
||||
$('task-conversation').value = conversationId
|
||||
$('task-content').focus()
|
||||
})
|
||||
$('close-form').addEventListener('click', () => {
|
||||
$('task-form').hidden = true
|
||||
$('new-task').setAttribute('aria-expanded', 'false')
|
||||
$('new-task').focus()
|
||||
})
|
||||
$('task-timing').addEventListener('change', () => {
|
||||
const scheduled = $('task-timing').value === 'scheduled'
|
||||
$('schedule-field').hidden = !scheduled
|
||||
$('task-date').required = scheduled
|
||||
$('task-date').disabled = !scheduled
|
||||
})
|
||||
$('task-content').addEventListener('input', () => $('task-content').setCustomValidity(''))
|
||||
$('task-date').addEventListener('input', () => $('task-date').setCustomValidity(''))
|
||||
$('task-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
const title = $('task-content').value.trim()
|
||||
$('task-content').setCustomValidity(title ? '' : '请输入任务内容')
|
||||
const scheduled = $('task-timing').value === 'scheduled'
|
||||
$('task-date').setCustomValidity(scheduled && !(new Date($('task-date').value).getTime() > Date.now()) ? '请选择未来的时间' : '')
|
||||
if (!$('task-form').reportValidity()) return
|
||||
let session = conversations.find((item) => item.id === $('task-conversation').value)
|
||||
if (!session) {
|
||||
session = { id: `new-${conversations.length}`, title: title.slice(0, 24), userCount: 0, assistantCount: 0, replies: [], tasks: [] }
|
||||
conversations.push(session)
|
||||
}
|
||||
session.tasks.unshift({ title, status: scheduled ? '待执行' : '进行中', duration: '0 秒', time: scheduled ? `计划于 ${$('task-date').value.replace('T', ' ')}` : '刚刚创建 · 模拟执行', active: true })
|
||||
$('task-status').value = 'all'
|
||||
$('task-form').reset()
|
||||
$('schedule-field').hidden = true
|
||||
$('task-date').required = false
|
||||
$('task-date').disabled = true
|
||||
$('task-form').hidden = true
|
||||
$('new-task').setAttribute('aria-expanded', 'false')
|
||||
render()
|
||||
const created = $('task-list').querySelector(`[data-session="${session.id}"][data-index="0"] .task-session`)
|
||||
created.focus()
|
||||
})
|
||||
render()
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 首期稳定 Task 生命周期、创建体验与 Conversation 输入仲裁已实现;高级触发和执行治理待实施 |
|
||||
| 版本 | 0.8 |
|
||||
| 日期 | 2026-09-10 |
|
||||
| 版本 | 0.9 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
||||
| 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) |
|
||||
|
||||
@@ -21,7 +21,7 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
|
||||
|
||||
创建 Scheduled Task 时:
|
||||
|
||||
1. 用户选择关联当前 Conversation 或创建新 Conversation。
|
||||
1. 默认关联当前 Conversation,也可选择本项目其他可用 Conversation 或创建新 Conversation。
|
||||
2. 系统创建一个 Task,并保存稳定 `conversationId` 和 Schedule/Trigger Binding。
|
||||
3. 每次触发在同一 Task 内创建新的 Job 和 Run。
|
||||
4. 面向用户的文本进展和结果写回关联 Conversation,并标明 Task 来源。
|
||||
@@ -34,11 +34,11 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
|
||||
|
||||
GoodBuddy 当前已实现首期统一生命周期:
|
||||
|
||||
- 创建 Modal 可以关联当前 Conversation 或原子创建新 Conversation,不修改当前
|
||||
- 创建 Modal 可以关联当前或本项目其他可用 Conversation,或原子创建新 Conversation,不修改已有
|
||||
Conversation 的标题和既有消息。
|
||||
- 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再
|
||||
为每次触发创建新的顶层 Task。
|
||||
- 创建表单只设置任务要求、关联会话和时间;工作模式在关联会话中设置,未设置时使用项目默认值。
|
||||
- 创建表单设置任务要求、可选名称、关联会话和执行时间,默认立即执行;工作模式在关联会话中设置,未设置时使用项目默认值。
|
||||
- 单次、每日和每周计划支持暂停、恢复、立即运行和应用重启恢复。
|
||||
- 到期和手动运行先进入关联 Conversation 的持久输入队列,与回复期间继续发送的普通消息
|
||||
顺序仲裁;默认不打断当前回复,也不与其并发写入时间线。
|
||||
@@ -58,7 +58,7 @@ Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间
|
||||
|
||||
- 支持单次、每日、每周、每月、工作日和受限 Cron。
|
||||
- 支持 Task 完成、失败、Conversation 完成等内部事件触发。
|
||||
- 创建时明确选择当前或新 Conversation。
|
||||
- 创建时明确选择当前、本项目其他可用或新 Conversation。
|
||||
- 使用关联会话当前的工作模式;新会话使用普通项目默认值。
|
||||
- 在实际发送时解析上下文和执行配置,与手动发送共用执行流程。
|
||||
- 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。
|
||||
@@ -82,10 +82,10 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
|
||||
|
||||
```text
|
||||
新建定制任务
|
||||
创建一个可以按计划自动运行,并持续记录在会话中的任务
|
||||
创建一个立即或按计划运行,并持续记录在会话中的任务
|
||||
|
||||
任务名称 *
|
||||
[ 每周项目总结 ]
|
||||
任务名称(可选)
|
||||
[ 留空时从任务内容生成 ]
|
||||
|
||||
任务要求 *
|
||||
[ 总结本周完成和失败的工作,并列出下周优先事项。 ]
|
||||
@@ -97,11 +97,13 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
|
||||
○ 新建会话
|
||||
为任务创建一条新会话,默认标题为任务名称
|
||||
|
||||
运行频率
|
||||
[ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ]
|
||||
○ 本项目其他会话
|
||||
[ 选择会话 ▾ ]
|
||||
|
||||
首次运行 [ 2026-08-21 ] [ 17:00 ]
|
||||
时区 [ Asia/Shanghai ▾ ]
|
||||
执行时间
|
||||
◉ 立即执行 ○ 定时执行
|
||||
定时选项 [ 单次 ] [ 每日 ] [ 每周 ]
|
||||
首次运行 [ 日期 ] [ 时间 ](仅定时执行时显示)
|
||||
|
||||
关联项目
|
||||
GoodBuddy Desktop · 项目工作目录
|
||||
@@ -112,14 +114,18 @@ GoodBuddy Desktop · 项目工作目录
|
||||
|
||||
### 5.1 Conversation 选择
|
||||
|
||||
- 从当前聊天发起时默认选择当前 Conversation。
|
||||
- 从 Task Center 发起时默认选择新 Conversation。
|
||||
- 从当前聊天或 Task Center 发起时均默认选择当前 Conversation。
|
||||
- 可选本项目其他可用 Conversation 或新 Conversation;当前会话不可用时明确提示用户选择,不隐藏目标。
|
||||
- 当前选择必须持续可见,不能根据入口静默决定后隐藏。
|
||||
- 关联当前 Conversation 不修改其标题、既有消息和普通聊天能力。
|
||||
- 当前 Conversation 已有关联 Task 时,显示 Task 数量和共享上下文说明。
|
||||
- 新 Conversation 默认使用 Task 名称作为标题,用户可以单独修改。
|
||||
- 远程通道、归档、正在删除或 Project 不匹配的 Conversation 不可选择,并显示原因。
|
||||
|
||||
任务内容必填。名称可留空,使用压缩连续空白后的内容前 120 个字符生成。默认立即执行;
|
||||
选择定时后支持单次、每日和每周,首次运行时间必须在未来。每月、工作日、Cron 和 IANA
|
||||
时区仍属于后续能力,不显示为本次已支持选项。
|
||||
|
||||
### 5.2 创建摘要
|
||||
|
||||
提交前显示确定性摘要:
|
||||
@@ -133,8 +139,13 @@ GoodBuddy Desktop · 项目工作目录
|
||||
✓ 独立交付物保存到成果
|
||||
```
|
||||
|
||||
摘要随目标会话和执行时间更新,默认立即执行时明确显示“创建后进入会话队列”。
|
||||
创建 Task、可选新 Conversation、关联关系和 Schedule Binding 必须在 Main 中原子提交。
|
||||
失败时保持 Modal 和用户输入,不只显示短暂通知。提交期间锁定重复操作。
|
||||
立即创建同时提交一次执行及其队列项,只入队一次,遵循同会话串行规则;自动定时触发不得
|
||||
再添加一次执行。事务失败保留 Modal 和输入;提交期间锁定重复操作。创建成功后的刷新失败
|
||||
只提示刷新错误,不重新创建;后续执行失败显示在已创建任务中。
|
||||
数据库事务与队列实现见[技术设计](./technical-design.md#custom-task-creation),其中 schedule run
|
||||
与 queue 和创建记录在同一事务内写入,前端创建后不再调用 `runNow`。
|
||||
|
||||
### 5.3 Modal 行为与无障碍
|
||||
|
||||
@@ -346,7 +357,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
- [x] 创建 Scheduled Task 可以选择当前或新 Conversation。
|
||||
- [ ] 创建默认立即执行并关联当前 Conversation,可选本项目其他可用会话或新会话;名称可留空从内容生成。
|
||||
- [ ] 立即创建原子提交且只入队一次,定时器不重复触发;入队失败回滚创建,刷新或执行失败不重复创建。
|
||||
- [x] 关联当前 Conversation 不修改其标题、类型或既有消息。
|
||||
- [x] 一条 Conversation 可以在左侧展开一个或多个 Task。
|
||||
- [x] 工作模式使用关联会话当前设置,未设置时使用项目默认值。
|
||||
@@ -369,3 +381,5 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
|
||||
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
|
||||
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
|
||||
- [ ] 应用重启不自动重放结果未知的副作用。
|
||||
|
||||
本次新增或变更的创建验收项未在本轮文档同步中执行验证,保留未勾选状态。
|
||||
|
||||
@@ -5,22 +5,20 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-10 |
|
||||
| 版本 | 0.5 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
||||
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 产品定义
|
||||
|
||||
Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center,
|
||||
也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
||||
Task Center 是工作栏中的单实例 Task 索引,范围固定为当前项目。顶部显示当前会话统计,
|
||||
下方管理本项目全部会话的任务。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
||||
|
||||
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
|
||||
Conversation 不因为关联 Task 而改变对象类型。
|
||||
|
||||
“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示
|
||||
当前项目的 Task。用户可以在同一 Tab 内切换到“所有项目”,不能为每个项目创建
|
||||
独立 Task Center 实例。
|
||||
应用内只有一个 Task Center Tab,不提供全局或所有项目入口,也不为每个项目创建独立实例。
|
||||
|
||||
## 2. 收录边界
|
||||
|
||||
@@ -45,7 +43,7 @@ Job/Subjob/Run 树、独立详情或路由。
|
||||
|
||||
每条 Task 至少显示:
|
||||
|
||||
- 名称和所属项目;无项目时标注“未绑定项目”。
|
||||
- 名称和所属项目。
|
||||
- 关联 Conversation 标题。
|
||||
- Task 类型和触发来源。
|
||||
- Ask / Execute 模式。
|
||||
@@ -54,25 +52,32 @@ Job/Subjob/Run 树、独立详情或路由。
|
||||
- 最近活动时间。
|
||||
- 等待审批、失败或需要关注状态。
|
||||
- 下次计划时间(如适用)。
|
||||
- 累计执行时间:汇总该任务历次执行请求的有效区间,不使用父 Task 从创建到结束的生命周期。
|
||||
|
||||
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
|
||||
|
||||
## 4. 范围
|
||||
|
||||
范围控件提供:
|
||||
FR-TC1:当前项目是唯一范围,列表只显示 `projectId` 匹配的 Task。切换项目刷新列表;
|
||||
同一项目内切换会话只更新顶部统计,不按当前会话筛掉项目任务。没有活动项目时提示选择项目。
|
||||
历史范围偏好不恢复全局或所有项目视图;范围收窄不删除历史任务或修改其项目绑定。
|
||||
|
||||
- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。
|
||||
- `所有项目`:显示当前用户可见的项目 Task 和没有 `projectId` 的历史 Task,后者标注“未绑定项目”。
|
||||
### 4.1 当前会话统计
|
||||
|
||||
没有活动项目时,“当前项目”显示明确空状态,并允许切换到“所有项目”;系统不
|
||||
自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存
|
||||
具体项目 ID;重启后继续跟随届时的活动项目。旧布局保存的 `global` 范围在 UI 恢复时映射为
|
||||
`all-projects`,后续保存使用新值;不修改任务数据或后端项目绑定规则。
|
||||
FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间。累计时间按每轮请求的有效
|
||||
执行区间相加,包含轮内工具执行和审批等待,不计轮间空闲;不重复累加子专家用时。
|
||||
缺失历史或时间证据不完整时提示“部分历史用时不可用”,已知用时不能表示为完整总量。
|
||||
远程执行缺少可靠时间证据时显示不可用或不完整,不用事件接收时间虚构远程用时。
|
||||
没有当前会话时提示选择会话,项目任务列表仍保留。
|
||||
|
||||
FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发仍显示在同一任务内。
|
||||
未执行与缺失执行证据必须区分;具体关联和统计边界见[技术设计](./technical-design.md)。
|
||||
|
||||
## 5. 交互
|
||||
|
||||
- 点击条目打开关联 Conversation,并定位到该 Task。
|
||||
- 状态筛选仅保留进行中、暂停、已结束,默认选择进行中。
|
||||
- FR-TC4:项目任务标题右侧依次放置“+”新建按钮和筛选图标。状态筛选默认收起、默认全部,
|
||||
展开后提供全部、进行中、暂停、已结束;收起不清除已选条件,非全部时图标高亮并显示筛选条件。
|
||||
- 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`;
|
||||
暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选,
|
||||
不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。
|
||||
@@ -83,6 +88,8 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
||||
- 审批徽标统计当前项目范围内全部待审批请求,不受状态筛选影响。
|
||||
- 支持立即运行、暂停、恢复、取消、编辑和删除。
|
||||
- 后台变化更新状态和徽标,不自动抢占当前页面。
|
||||
- FR-TC5:创建默认立即执行并关联当前会话,也可选择本项目其他可用会话或新建会话,
|
||||
改用单次定时、每日或每周计划。内容必填,名称可留空并从内容生成,详见[创建规则](./scheduled-task-prd.md#5-创建入口与-modal)。
|
||||
- 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示。
|
||||
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
|
||||
独立交付物在成果中查看。
|
||||
@@ -145,6 +152,11 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
||||
- [x] 普通模型请求和工具调用不会误显示为 Task。
|
||||
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
|
||||
- [x] Smart Heartbeat 不进入 Task Center。
|
||||
- [x] Task Center 默认只显示当前项目的 Task,切换项目后在同一 Tab 更新列表。
|
||||
- [x] 仅提供“当前项目”和“所有项目”;后者保留无项目 Task,并标注“未绑定项目”。
|
||||
- [x] 范围切换不创建第二个 Task Center Tab;任务中心保持默认存在且不可关闭。
|
||||
- [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。
|
||||
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。
|
||||
- [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。
|
||||
- [ ] FR-TC4:标题右侧先新建、后筛选;筛选默认收起且为全部,收起后保留所选条件。
|
||||
- [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。
|
||||
- [x] 任务中心保持单实例、默认存在且不可关闭。
|
||||
|
||||
本次新增或变更的验收项未在本轮文档同步中执行验证,保留未勾选状态。
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Execution Statistics Query
|
||||
|
||||
## Contract
|
||||
|
||||
`window.goodbuddy.tasks.getExecutionStats(input)` invokes `tasks:execution-stats`.
|
||||
`ExecutionStatsInput` accepts exactly one UUID scope: `{ conversationId }` or
|
||||
`{ projectId }`. Main validates the scope and trusted sender. The database returns:
|
||||
|
||||
```ts
|
||||
interface ExecutionStats {
|
||||
durationMs: number
|
||||
requestCount: number
|
||||
incompleteRequestCount: number
|
||||
activeRequestCount: number
|
||||
asOf: number // epoch milliseconds at query time
|
||||
taskDurations: Array<{
|
||||
id: string
|
||||
durationMs: number
|
||||
incompleteRequestCount: number
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
The project scope sums request evidence across its conversations and returns card
|
||||
totals in `taskDurations`. A scheduled execution uses `requestId = schedule_runs.id`;
|
||||
join its `schedule_id` to the stable task's `schedule_id` to aggregate repeated runs.
|
||||
Request creation also saves that proven relation in `parent_task_id`. Before plan
|
||||
deletion cascades its run records, bind older requests that still lack this link.
|
||||
Statistics can then use the retained link to a visible `origin = schedule` parent,
|
||||
even after the parent's `schedule_id` has been cleared. A shared conversation alone
|
||||
does not establish ownership. Other
|
||||
identified requests retain their own task ID. Visible tasks with no executions
|
||||
have a zero entry. Conversation queries return an empty `taskDurations` array.
|
||||
Message counts used to detect missing history stay in Main; no bodies are returned.
|
||||
|
||||
## Evidence And Boundaries
|
||||
|
||||
- Query all retained scoped task records, including hidden chat requests, without
|
||||
the visible-task list's pagination. Require a persisted message `request_id`
|
||||
link, a reply event (`text`, `reasoning`, `tool`, `done`, `error`, `subagent`,
|
||||
`generated-image`, or `question`) associated by `task_events.task_id`, a schedule
|
||||
run association, or a current reply lease. Local message snapshots do not persist
|
||||
request links, so local replies normally use event membership and kind. Do not
|
||||
parse each stream/tool payload merely to repeat its owning request ID.
|
||||
- Runtime status events also prove a reply when `requestId` matches the owning
|
||||
task, `type` is `status`, and `message` is a string. Maintenance status events
|
||||
such as `{ status: 'running' }` do not. Status-only interrupted replies retain
|
||||
their last evidenced interval just like text/tool replies.
|
||||
- Exclude stable schedule parents (`schedule_id` is set) and expert tasks
|
||||
(`origin = subagent`). Scheduled reply requests remain eligible. Nested expert
|
||||
execution is already inside the parent's elapsed interval and is not added again.
|
||||
- Maintenance-only records do not establish reply identity. This excludes manual
|
||||
context compression, heartbeat summaries, and unexecuted suggestions. A record
|
||||
with no reply identity evidence cannot be classified; it is not counted.
|
||||
- Sum intervals from `started_at` or later `running` status events to terminal
|
||||
events/statuses. Pause/queue closes an interval; a later run starts another.
|
||||
Repeated approval/running transitions do not reset the start. Tools and approval
|
||||
waits inside a reply are included; idle gaps between replies/runs are excluded.
|
||||
- `interrupted` may be written at application restart. Count only through the
|
||||
preceding evidence, mark the request incomplete, and never use its recovery
|
||||
`completed_at` as an execution endpoint. Missing or invalid timing also marks
|
||||
the request incomplete; unknown time is not replaced with zero-length certainty.
|
||||
- A non-aborted reply lease confirms current execution: an open local interval
|
||||
with a reliable start contributes through `asOf` and is not marked incomplete
|
||||
just because it is active. Manual context compression leases are excluded.
|
||||
Without a live lease, stop at the latest evidence and mark an open interval
|
||||
incomplete. Renderer must requery rather than extrapolate beyond `asOf`.
|
||||
- Remote-recoverable records, tasks in persisted SSH project execution spaces,
|
||||
and events with remote operation provenance are
|
||||
incomplete and contribute no duration: their receipt timestamps cannot establish
|
||||
remote execution intervals, particularly after replay. No Runtime, Agent, or
|
||||
remote persistence behavior changes are required by this read-only query.
|
||||
The SSH project check applies before the first provenance event, including
|
||||
channel/delegation requests with `remote_recoverable = false`, so an unknown
|
||||
remote duration is not first reported as complete local execution time.
|
||||
- Completed, failed, or running schedule runs with no request history count as
|
||||
incomplete, including in their stable task card. Pending runs do not count.
|
||||
- Former schedule parents are never execution clocks, including after deletion.
|
||||
A removed plan without retained execution ownership is reported as incomplete,
|
||||
not a complete zero; its former lifetime cannot reconstruct missing runs.
|
||||
- Retained top-level assistant messages establish missing-history evidence.
|
||||
Explicit missing request links count as incomplete; unlinked snapshots use a
|
||||
count comparison within each conversation/task, after covering linked replies.
|
||||
Missing schedule runs already counted above cover their messages, avoiding a
|
||||
second missing count. Expert-task messages and nested subagent content are not
|
||||
counted again. A wholly missing reply contributes zero known duration and one
|
||||
incomplete request, not a complete zero.
|
||||
- Exclude the first local assistant message only when it has no request/task link
|
||||
and exactly matches a shipped Chinese/English default greeting. Persisted
|
||||
greetings have no separate marker. Other assistant-only history and identical
|
||||
text later in the conversation remain eligible; do not discard all messages
|
||||
before the first user turn.
|
||||
- These counts are a lower bound when local snapshots have no request links;
|
||||
fully deleted history cannot be reconstructed. `requestCount` includes replies
|
||||
inferred from missing-history evidence. Concurrent top-level requests may
|
||||
overlap in wall-clock time; their durations are summed independently.
|
||||
|
||||
The query materializes scoped task evidence once, then returns only status and
|
||||
terminal events. Only status payloads use JSON extraction. Request membership and
|
||||
remote provenance inspect scalar event columns; last-event and pre-interruption
|
||||
timestamps use `(task_id, id)` index seeks. This preserves a tool/delta event as
|
||||
the last reliable boundary without loading its body. Grouped assistant-message
|
||||
counts still read task metadata from message rows rather than from every event.
|
||||
It adds no tables, columns, migration, or persisted timing totals. Stable task
|
||||
lifecycle timestamps are never duration endpoints.
|
||||
|
||||
## Validation Coverage
|
||||
|
||||
The focused execution-statistics tests cover hidden requests, scope isolation,
|
||||
approval waits, repeated intervals, restart interruption, remote replay, missing
|
||||
start times, and history beyond visible-task pagination. IPC tests cover sender
|
||||
and scope validation; preload tests cover typed forwarding. Renderer integration
|
||||
must supply `getExecutionStats` in its `DesktopApi.tasks` mocks.
|
||||
|
||||
The large-payload regression inspects the production query plan, measures paired
|
||||
conversation/project polling on 3,000 events with approximately 94 MiB of payload,
|
||||
and replaces stream/tool bodies with invalid JSON to prove they are not parsed.
|
||||
Run it with `npx vitest run src/main/assistant/assistant-execution-stats.test.ts -t seeks --reporter=verbose --disableConsoleIntercept`
|
||||
to print timings. These are synthetic warm-query measurements, not a guarantee
|
||||
for arbitrarily large histories. Scalar event filtering and per-message metadata
|
||||
work still grow with retained history; if that becomes measurable in deployment,
|
||||
prefer polling only during active execution and refreshing on history/task changes.
|
||||
|
||||
## Custom Task Creation
|
||||
|
||||
The dialog defaults to the current conversation and immediate execution. Content
|
||||
is required; an empty name uses the first 120 characters of whitespace-normalized
|
||||
content. App supplies other local conversations in the current project and also
|
||||
allows creating a new conversation. Scheduled creation retains once/daily/weekly
|
||||
recurrence and requires a future timestamp, checked by both App and Main IPC.
|
||||
|
||||
`ScheduleCreateInput.runImmediately` is an optional creation command, not a stored
|
||||
schedule field. It is valid only for `recurrence: 'once'`. `createSchedule` creates
|
||||
the conversation (if needed), task, disabled schedule, pending schedule run and
|
||||
conversation queue item in one SQLite transaction. Queue insertion failure rolls
|
||||
back all of them. Disabling automatic scheduling prevents timer ticks from adding
|
||||
another occurrence; pending/running queue state still determines task status.
|
||||
|
||||
The create IPC handler publishes the queue change and wakes the existing
|
||||
conversation queue pump. App does not follow creation with `runNow`. Execution
|
||||
uses the existing readiness and conversation serialization rules. A runtime
|
||||
failure belongs to the created task; it does not reject creation or create another
|
||||
task. Refresh failures after a successful creation are notifications, not a reason
|
||||
to retry creation. No schema migration or preload changes are required.
|
||||
|
||||
`schedule-creation.test.ts` verifies transaction rollback and timer non-duplication
|
||||
with real SQLite. IPC tests exercise that database through the production queue
|
||||
and ordinary run handler with a controlled runtime, including failed execution.
|
||||
App creation tests cover destination selection, time validation, and failure handling.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApplicationSettingsStore,
|
||||
applicationSettingsSchema,
|
||||
@@ -44,6 +44,55 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('ApplicationSettingsStore', () => {
|
||||
it('keeps the confirmed snapshot when an atomic save fails', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const confirmed = await store.get()
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
await mkdir(filePath)
|
||||
await expect(store.update({ localInferenceEnabled: false })).rejects.toThrow()
|
||||
expect(await store.get()).toEqual(confirmed)
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults missing released Notes fields to enabled without resetting unrelated values', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, 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 createApplicationSettingsStore(filePath).get()).toEqual(await store.get())
|
||||
})
|
||||
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' }
|
||||
for (const key of ['applicationNavigation', 'localInferenceEnabled']) delete legacy[key]
|
||||
await writeFile(filePath, JSON.stringify(legacy), 'utf8')
|
||||
const migrated = await store.get()
|
||||
expect(migrated).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
|
||||
await store.update({ localInferenceEnabled: false })
|
||||
await store.update({ applicationNavigation: { ...migrated.applicationNavigation, order: [...migrated.applicationNavigation.order].reverse(), pinned: { ...migrated.applicationNavigation.pinned, 'local-inference': false } } })
|
||||
const reloaded = await createApplicationSettingsStore(filePath).get()
|
||||
expect(reloaded).toMatchObject({ magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, localInferenceEnabled: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' })
|
||||
expect(reloaded.applicationNavigation.order).toEqual([...migrated.applicationNavigation.order].reverse())
|
||||
expect(reloaded.applicationNavigation.pinned['local-inference']).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects incomplete, duplicate, unknown order and pin values without applying defaults to patches', () => {
|
||||
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', 'local-inference'] },
|
||||
{ ...navigation, order: ['heartbeat', 'local-inference'] },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, heartbeat: true } },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, knowledge: true } },
|
||||
{ ...navigation, pinned: { ...navigation.pinned, extra: true } },
|
||||
{ ...navigation, pinned: { knowledge: true } }
|
||||
]) expect(applicationSettingsUpdateSchema.safeParse({ applicationNavigation: invalid }).success).toBe(false)
|
||||
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
|
||||
})
|
||||
it('returns defaults without creating a settings file', async () => {
|
||||
const { directory, store } = await createStore()
|
||||
|
||||
@@ -61,6 +110,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNotesShowIncompleteTodoCount: false
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -73,6 +123,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -85,7 +136,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -119,7 +171,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
remoteProjectsEnabled: true
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
version: 12,
|
||||
lastSeenReleaseNotesVersion: null,
|
||||
...defaultApplicationSettings,
|
||||
remoteProjectsEnabled: true
|
||||
@@ -138,6 +190,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(
|
||||
createApplicationSettingsStore(filePath).get()
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -152,7 +205,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
|
||||
it.each([1, 2])(
|
||||
'loads version %s settings missing the field with Magic Notes disabled',
|
||||
'loads version %s settings missing the field with Magic Notes enabled',
|
||||
async (version) => {
|
||||
const { filePath, store } = await createStore()
|
||||
await writeFile(
|
||||
@@ -165,13 +218,14 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -192,6 +246,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -219,6 +274,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -252,7 +308,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
|
||||
).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -283,6 +340,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
)
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -311,6 +369,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await writeFile(filePath, JSON.stringify(versionSix), 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -323,8 +382,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'structured'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSix,
|
||||
version: 11,
|
||||
version: 12,
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -334,8 +394,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await store.update({ modelDownloadSource: 'hugging-face' })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSix,
|
||||
version: 11,
|
||||
version: 12,
|
||||
modelDownloadSource: 'hugging-face',
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -359,6 +420,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await writeFile(filePath, JSON.stringify(versionSeven), 'utf8')
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -371,8 +433,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionSeven,
|
||||
version: 11,
|
||||
version: 12,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
@@ -398,6 +461,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
const migratedSettings = await store.get()
|
||||
expect(migratedSettings).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'hugging-face',
|
||||
@@ -411,8 +475,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionEight,
|
||||
version: 11,
|
||||
version: 12,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false
|
||||
@@ -420,8 +485,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
await store.update({ checkUpdatesOnStartup: true })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionEight,
|
||||
version: 11,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: true,
|
||||
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
@@ -454,6 +520,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
})
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'mirror',
|
||||
modelDownloadSource: 'hugging-face',
|
||||
@@ -476,8 +543,9 @@ describe('ApplicationSettingsStore', () => {
|
||||
|
||||
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
|
||||
expect(persisted).toEqual({
|
||||
...defaultApplicationSettings,
|
||||
...versionNine,
|
||||
version: 11,
|
||||
version: 12,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
localToolEnvironment: {
|
||||
node: {
|
||||
@@ -538,6 +606,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(
|
||||
store.update({ checkUpdatesOnStartup: false })
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -596,7 +665,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
...versionTenSettings,
|
||||
version: 11,
|
||||
version: 12,
|
||||
conversationHtmlRenderingEnabled: true
|
||||
})
|
||||
})
|
||||
@@ -606,7 +675,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
checkUpdatesOnStartup: false,
|
||||
magicNotesEnabled: false
|
||||
magicNotesEnabled: 'false'
|
||||
}),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
@@ -685,6 +754,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
])
|
||||
|
||||
await expect(store.get()).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -697,7 +767,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNoteCommentFormat: 'combined'
|
||||
})
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
|
||||
version: 11,
|
||||
...defaultApplicationSettings,
|
||||
version: 12,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
@@ -727,6 +798,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
).resolves.toEqual({
|
||||
...defaultApplicationSettings,
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
applicationSettingsUpdateSchema,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../shared/application-settings-contracts'
|
||||
import {
|
||||
@@ -27,7 +28,7 @@ export {
|
||||
} from '../shared/application-settings-contracts'
|
||||
export type { ApplicationSettings } from '../shared/application-settings-contracts'
|
||||
|
||||
const CURRENT_SETTINGS_VERSION = 11
|
||||
const CURRENT_SETTINGS_VERSION = 12
|
||||
|
||||
const legacyStoredApplicationSettingsSchema = z
|
||||
.object({
|
||||
@@ -148,6 +149,8 @@ const storedApplicationSettingsSchema = applicationSettingsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const versionElevenStoredApplicationSettingsSchema = storedApplicationSettingsSchema.extend({ version: z.literal(11) })
|
||||
|
||||
type StoredApplicationSettings = z.infer<typeof storedApplicationSettingsSchema>
|
||||
|
||||
export type LegacyLocalToolEnvironmentPaths = {
|
||||
@@ -234,7 +237,9 @@ export const defaultApplicationSettings: ApplicationSettings = {
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
applicationNavigation: defaultApplicationNavigation,
|
||||
localInferenceEnabled: true,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined'
|
||||
@@ -245,18 +250,26 @@ export class ApplicationSettingsStore {
|
||||
private settingsLoad?: Promise<StoredApplicationSettings>
|
||||
private warnings: SettingsWarning[] = []
|
||||
private updateQueue: Promise<void> = Promise.resolve()
|
||||
private readonly listeners = new Set<(settings: ApplicationSettings) => void>()
|
||||
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly legacyLocalToolEnvironmentResolver: LegacyLocalToolEnvironmentResolver = resolveLegacyLocalToolEnvironmentPaths
|
||||
) {}
|
||||
|
||||
onChanged(listener: (settings: ApplicationSettings) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
private async migrateLegacy(
|
||||
settings: Omit<
|
||||
StoredApplicationSettings,
|
||||
| 'conversationHtmlRenderingEnabled'
|
||||
| 'localToolEnvironment'
|
||||
| 'version'
|
||||
| 'applicationNavigation'
|
||||
| 'localInferenceEnabled'
|
||||
> & {
|
||||
conversationHtmlRenderingEnabled?: boolean
|
||||
version: number
|
||||
@@ -271,6 +284,7 @@ export class ApplicationSettingsStore {
|
||||
? { source: 'custom', executablePath }
|
||||
: { source: 'managed' }
|
||||
const next: StoredApplicationSettings = {
|
||||
...defaultApplicationSettings,
|
||||
...settings,
|
||||
conversationHtmlRenderingEnabled:
|
||||
settings.conversationHtmlRenderingEnabled ?? true,
|
||||
@@ -328,6 +342,12 @@ export class ApplicationSettingsStore {
|
||||
)
|
||||
const result = storedApplicationSettingsSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const versionElevenResult = versionElevenStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionElevenResult.success) {
|
||||
const next: StoredApplicationSettings = { ...versionElevenResult.data, version: CURRENT_SETTINGS_VERSION }
|
||||
await this.persist(next)
|
||||
return next
|
||||
}
|
||||
const versionTenResult =
|
||||
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
|
||||
if (versionTenResult.success) {
|
||||
@@ -437,7 +457,7 @@ export class ApplicationSettingsStore {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
magicNotesShowIncompleteTodoCount: true,
|
||||
magicNoteCommentMode: 'immediate',
|
||||
magicNoteCommentFormat: 'combined',
|
||||
@@ -482,6 +502,8 @@ export class ApplicationSettingsStore {
|
||||
conversationHtmlRenderingEnabled:
|
||||
stored.conversationHtmlRenderingEnabled,
|
||||
remoteProjectsEnabled: stored.remoteProjectsEnabled,
|
||||
applicationNavigation: stored.applicationNavigation,
|
||||
localInferenceEnabled: stored.localInferenceEnabled,
|
||||
magicNotesEnabled: stored.magicNotesEnabled,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
stored.magicNotesShowIncompleteTodoCount,
|
||||
@@ -511,7 +533,7 @@ export class ApplicationSettingsStore {
|
||||
}
|
||||
await this.persist(next)
|
||||
this.warnings = []
|
||||
return {
|
||||
const settings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
|
||||
updateSource: next.updateSource,
|
||||
modelDownloadSource: next.modelDownloadSource,
|
||||
@@ -519,12 +541,16 @@ export class ApplicationSettingsStore {
|
||||
conversationHtmlRenderingEnabled:
|
||||
next.conversationHtmlRenderingEnabled,
|
||||
remoteProjectsEnabled: next.remoteProjectsEnabled,
|
||||
applicationNavigation: next.applicationNavigation,
|
||||
localInferenceEnabled: next.localInferenceEnabled,
|
||||
magicNotesEnabled: next.magicNotesEnabled,
|
||||
magicNotesShowIncompleteTodoCount:
|
||||
next.magicNotesShowIncompleteTodoCount,
|
||||
magicNoteCommentMode: next.magicNoteCommentMode,
|
||||
magicNoteCommentFormat: next.magicNoteCommentFormat
|
||||
}
|
||||
for (const listener of this.listeners) listener(settings)
|
||||
return settings
|
||||
})
|
||||
this.updateQueue = operation.then(
|
||||
() => undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
assistantIdSchema,
|
||||
executionStatsInputSchema,
|
||||
conversationAnsweredQuestionSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
@@ -26,6 +27,8 @@ import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
ExecutionStats,
|
||||
ExecutionStatsInput,
|
||||
ActivityHistorySnapshot,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
@@ -4317,6 +4320,230 @@ export class AssistantDatabase {
|
||||
return rows.map(toTask)
|
||||
}
|
||||
|
||||
getExecutionStats(
|
||||
input: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string> = new Set()
|
||||
): ExecutionStats {
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
const result: ExecutionStats = {
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
|
||||
}
|
||||
const database = this.requireDatabase()
|
||||
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
|
||||
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
|
||||
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
|
||||
const observed = new Set<string>()
|
||||
const coverage = new Map<string, number>()
|
||||
const bucket = (conversationId: string | null, owner: string | null): string =>
|
||||
JSON.stringify([conversationId, owner])
|
||||
const add = (id: string | null, durationMs: number, incomplete: number): void => {
|
||||
if (!id || !('projectId' in scope)) return
|
||||
const entry = durations.get(id) ?? { id, durationMs: 0, incompleteRequestCount: 0 }
|
||||
entry.durationMs += durationMs
|
||||
entry.incompleteRequestCount += incomplete
|
||||
durations.set(id, entry)
|
||||
}
|
||||
// Materialize task-level evidence once. Stream/tool payloads are never parsed;
|
||||
// task_events_task_idx supplies request membership and boundary seeks.
|
||||
const rows = database.prepare(`
|
||||
WITH scoped AS MATERIALIZED (
|
||||
SELECT t.id, t.conversation_id, t.visible, t.started_at, t.remote_recoverable,
|
||||
COALESCE(parent.id, retained.id) AS schedule_task_id,
|
||||
space.kind = 'ssh' AS ssh_project,
|
||||
EXISTS (SELECT 1 FROM messages m
|
||||
WHERE m.conversation_id = t.conversation_id AND m.request_id = t.id) AS linked,
|
||||
EXISTS (SELECT 1 FROM task_events proof WHERE proof.task_id = t.id
|
||||
AND proof.kind IN ('text', 'reasoning', 'tool', 'done', 'error',
|
||||
'subagent', 'generated-image', 'question')) AS reply_event,
|
||||
EXISTS (SELECT 1 FROM task_events remote WHERE remote.task_id = t.id
|
||||
AND remote.remote_operation_id IS NOT NULL) AS remote_event,
|
||||
(SELECT tail.created_at FROM task_events tail WHERE tail.task_id = t.id
|
||||
ORDER BY tail.id DESC LIMIT 1) AS last_at
|
||||
FROM tasks t
|
||||
LEFT JOIN schedule_runs sr ON sr.id = t.id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = t.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
LEFT JOIN project_execution_spaces space ON space.project_id = t.project_id
|
||||
WHERE t.${scopeColumn} = ?
|
||||
AND t.schedule_id IS NULL AND t.origin != 'subagent'
|
||||
AND NOT (t.origin = 'schedule' AND t.visible = 1)
|
||||
)
|
||||
SELECT scoped.*, e.kind, e.created_at,
|
||||
CASE WHEN e.kind = 'status'
|
||||
THEN json_extract(e.payload_json, '$.status') END AS event_status,
|
||||
CASE WHEN e.kind = 'status' THEN
|
||||
json_extract(e.payload_json, '$.requestId') = scoped.id
|
||||
AND json_extract(e.payload_json, '$.type') = 'status'
|
||||
AND json_type(e.payload_json, '$.message') = 'text'
|
||||
END AS runtime_status,
|
||||
CASE WHEN e.kind = 'status' THEN (
|
||||
SELECT previous.created_at FROM task_events previous
|
||||
WHERE previous.task_id = scoped.id AND previous.id < e.id
|
||||
ORDER BY previous.id DESC LIMIT 1
|
||||
) END AS previous_at
|
||||
FROM scoped LEFT JOIN task_events e ON e.task_id = scoped.id
|
||||
AND e.kind IN ('status', 'done', 'error')
|
||||
ORDER BY scoped.id, e.id
|
||||
`).iterate(scopeId)
|
||||
let task: {
|
||||
id: string; proven: boolean; remote: boolean; incomplete: boolean
|
||||
start: number | undefined; last: number | undefined; duration: number
|
||||
conversationId: string | null; owner: string | null
|
||||
lastEvidence: number
|
||||
} | undefined
|
||||
const finish = (): void => {
|
||||
if (!task?.proven) return
|
||||
result.requestCount++
|
||||
observed.add(task.id)
|
||||
const key = bucket(task.conversationId, task.owner)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
if (activeRequestIds.has(task.id)) result.activeRequestCount++
|
||||
if (task.remote) {
|
||||
task.duration = 0
|
||||
task.incomplete = true
|
||||
} else if (task.start !== undefined) {
|
||||
if (Number.isFinite(task.lastEvidence) && task.lastEvidence >= task.start && task.lastEvidence <= result.asOf) {
|
||||
task.last = Math.max(task.last ?? task.start, task.lastEvidence)
|
||||
}
|
||||
const live = activeRequestIds.has(task.id) && task.start <= result.asOf
|
||||
task.duration += Math.max(0, (live ? result.asOf : task.last ?? task.start) - task.start)
|
||||
task.incomplete ||= !live
|
||||
}
|
||||
result.durationMs += task.duration
|
||||
if (task.incomplete) result.incompleteRequestCount++
|
||||
add(task.owner ?? task.id, task.duration, Number(task.incomplete))
|
||||
}
|
||||
for (const raw of rows) {
|
||||
const row = raw as unknown as {
|
||||
id: string; started_at: string | null; remote_recoverable: number
|
||||
linked: number; kind: string | null; created_at: string | null
|
||||
reply_event: number; remote_event: number; last_at: string | null; previous_at: string | null
|
||||
event_status: string | null; runtime_status: number | null; ssh_project: number | null
|
||||
conversation_id: string | null; visible: number; schedule_task_id: string | null
|
||||
}
|
||||
if (task?.id !== row.id) {
|
||||
finish()
|
||||
const start = Date.parse(row.started_at ?? '')
|
||||
task = {
|
||||
id: row.id, proven: row.linked === 1 || row.reply_event === 1 || row.schedule_task_id !== null || activeRequestIds.has(row.id),
|
||||
remote: row.remote_recoverable === 1 || row.remote_event === 1 || row.ssh_project === 1,
|
||||
lastEvidence: Date.parse(row.last_at ?? ''),
|
||||
conversationId: row.conversation_id,
|
||||
owner: row.schedule_task_id ?? (row.visible === 1 ? row.id : null),
|
||||
incomplete: !Number.isFinite(start),
|
||||
start: Number.isFinite(start) ? start : undefined,
|
||||
last: Number.isFinite(start) ? start : undefined, duration: 0
|
||||
}
|
||||
}
|
||||
const current = task!
|
||||
current.proven ||= row.runtime_status === 1
|
||||
if (row.kind === null) continue
|
||||
const status = row.event_status
|
||||
// Recovery writes interrupted at restart, not when execution actually stopped.
|
||||
if (status === 'interrupted') {
|
||||
if (current.start !== undefined) {
|
||||
const previous = Date.parse(row.previous_at ?? '')
|
||||
if (Number.isFinite(previous) && previous >= current.start && previous <= result.asOf) {
|
||||
current.last = Math.max(current.last ?? current.start, previous)
|
||||
}
|
||||
current.duration += Math.max(0, (current.last ?? current.start) - current.start)
|
||||
}
|
||||
current.start = undefined
|
||||
current.last = undefined
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
const time = Date.parse(row.created_at ?? '')
|
||||
if (!Number.isFinite(time) || time > result.asOf || (current.last !== undefined && time < current.last)) {
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
if (status === 'running' && current.start === undefined) current.start = time
|
||||
const closes = row.kind === 'done' || row.kind === 'error' ||
|
||||
['completed', 'failed', 'cancelled', 'paused', 'queued', 'idle'].includes(status ?? '')
|
||||
if (closes && current.start !== undefined) {
|
||||
current.duration += time - current.start
|
||||
current.start = undefined
|
||||
}
|
||||
current.last = time
|
||||
}
|
||||
finish()
|
||||
|
||||
// A schedule run has an independent request ID. Its stable task is never a clock.
|
||||
for (const row of database.prepare(`
|
||||
SELECT t.id, t.conversation_id, t.origin, t.schedule_id,
|
||||
sr.id AS run_id, sr.status AS run_status
|
||||
FROM tasks t LEFT JOIN schedule_runs sr ON sr.schedule_id = t.schedule_id
|
||||
WHERE t.${scopeColumn} = ? AND t.visible = 1 AND t.origin != 'subagent'
|
||||
ORDER BY t.id, sr.id
|
||||
`).iterate(scopeId)) {
|
||||
const id = row.id as string
|
||||
add(id, 0, 0)
|
||||
const key = bucket(row.conversation_id as string | null, id)
|
||||
// A removed plan without retained execution links cannot establish a zero.
|
||||
if (row.origin === 'schedule' && row.schedule_id === null && !coverage.has(key)) {
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, 1)
|
||||
}
|
||||
if (row.run_id && ['completed', 'failed', 'running'].includes(row.run_status as string) && !observed.has(row.run_id as string)) {
|
||||
observed.add(row.run_id as string)
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
// Local snapshots lack request IDs. Compare top-level assistant rows per
|
||||
// conversation/task, never nested subagent blocks or child-task messages.
|
||||
const messages = database.prepare(`
|
||||
SELECT m.conversation_id, m.request_id,
|
||||
COALESCE(parent.id, retained.id, json_extract(m.metadata_json, '$.task.id'),
|
||||
CASE WHEN mt.visible = 1 THEN mt.id END) AS owner,
|
||||
COUNT(*) AS count
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
||||
LEFT JOIN tasks mt ON mt.id = m.request_id
|
||||
LEFT JOIN schedule_runs sr ON sr.id = m.request_id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = mt.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
WHERE ${'conversationId' in scope ? 'm.conversation_id' : 'c.project_id'} = ?
|
||||
AND m.role = 'assistant'
|
||||
AND NOT (c.channel IS NULL AND m.sequence = 0 AND m.request_id IS NULL
|
||||
AND json_extract(m.metadata_json, '$.task.id') IS NULL
|
||||
AND m.content IN (?, ?))
|
||||
AND COALESCE(mt.origin, '') != 'subagent'
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks child WHERE child.origin = 'subagent'
|
||||
AND child.id = json_extract(m.metadata_json, '$.task.id'))
|
||||
GROUP BY m.conversation_id, owner, m.request_id
|
||||
ORDER BY m.request_id IS NULL
|
||||
`).all(
|
||||
scopeId,
|
||||
// Persisted greetings have no marker. Match only shipped default copy,
|
||||
// not arbitrary assistant-only/imported history or localized prefixes.
|
||||
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
|
||||
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.'
|
||||
)
|
||||
for (const row of messages) {
|
||||
const owner = row.owner as string | null
|
||||
const key = bucket(row.conversation_id as string, owner)
|
||||
const available = coverage.get(key) ?? 0
|
||||
const linked = row.request_id as string | null
|
||||
const count = linked ? 1 : row.count as number
|
||||
const covered = linked ? Number(observed.has(linked)) : Math.min(count, available)
|
||||
coverage.set(key, Math.max(0, available - covered))
|
||||
const missing = count - covered
|
||||
result.requestCount += missing
|
||||
result.incompleteRequestCount += missing
|
||||
add(owner, 0, missing)
|
||||
}
|
||||
result.taskDurations = [...durations.values()]
|
||||
return result
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
@@ -4424,13 +4651,18 @@ export class AssistantDatabase {
|
||||
work_mode, progress, created_at, started_at, visible,
|
||||
remote_recoverable, current_user_message_id,
|
||||
current_assistant_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
|
||||
VALUES (?, ?, ?, COALESCE((
|
||||
SELECT parent.id FROM schedule_runs sr
|
||||
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
WHERE sr.id = ?
|
||||
), ?), ?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?, ?,
|
||||
?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
taskId,
|
||||
input.projectId ?? null,
|
||||
input.conversationId ?? null,
|
||||
taskId,
|
||||
input.parentTaskId ?? null,
|
||||
input.expertId ?? null,
|
||||
input.routingMode ?? null,
|
||||
@@ -6486,7 +6718,7 @@ export class AssistantDatabase {
|
||||
(id, project_id, task_template_json, timezone, recurrence_json,
|
||||
next_run_at, missed_run_policy, enabled, last_run_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', 1, NULL, ?, ?)`
|
||||
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
scheduleId,
|
||||
@@ -6497,7 +6729,11 @@ export class AssistantDatabase {
|
||||
workMode: parsed.workMode
|
||||
}),
|
||||
JSON.stringify({ type: parsed.recurrence }),
|
||||
parsed.nextRunAt,
|
||||
parsed.runImmediately ? now : parsed.nextRunAt,
|
||||
parsed.runImmediately ? 0 : 1,
|
||||
// Immediate creation consumes the once trigger even if its queue item
|
||||
// is cancelled. Further execution must use an explicit manual run.
|
||||
parsed.runImmediately ? now : null,
|
||||
now,
|
||||
now
|
||||
)
|
||||
@@ -6521,6 +6757,22 @@ export class AssistantDatabase {
|
||||
parsed.workMode,
|
||||
now
|
||||
)
|
||||
if (parsed.runImmediately) {
|
||||
// Commit the task and its only automatic occurrence together. The timer
|
||||
// must never see an enabled immediate schedule between create and queue.
|
||||
const runId = randomUUID()
|
||||
database.prepare(
|
||||
`INSERT INTO schedule_runs
|
||||
(id, schedule_id, scheduled_for, task_id, status)
|
||||
VALUES (?, ?, ?, ?, 'pending')`
|
||||
).run(runId, scheduleId, now, taskId)
|
||||
database.prepare(
|
||||
`INSERT INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
schedule_run_id, schedule_id, task_id, status, created_at)
|
||||
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
|
||||
).run(runId, conversationId, parsed.title, runId, scheduleId, taskId, now)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -6605,6 +6857,16 @@ export class AssistantDatabase {
|
||||
if (activeRun) {
|
||||
throw new Error('定时任务正在运行,完成后才能删除计划')
|
||||
}
|
||||
// Preserve older requests' proven run ownership before the cascade removes it.
|
||||
database.prepare(`
|
||||
UPDATE tasks AS request SET parent_task_id = (
|
||||
SELECT parent.id FROM schedule_runs sr
|
||||
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
WHERE sr.id = request.id AND sr.schedule_id = ?
|
||||
)
|
||||
WHERE request.parent_task_id IS NULL
|
||||
AND request.id IN (SELECT id FROM schedule_runs WHERE schedule_id = ?)
|
||||
`).run(scheduleId, scheduleId)
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
describe('execution duration evidence', () => {
|
||||
let directory: string
|
||||
let database: AssistantDatabase
|
||||
let projectId: string
|
||||
let conversationId: string
|
||||
const epoch = Date.parse('2026-09-17T00:00:00Z')
|
||||
const at = (seconds: number): void => { vi.setSystemTime(epoch + seconds * 1000) }
|
||||
const request = (overrides: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
|
||||
const id = randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply', workMode: 'ask', visible: false, ...overrides })
|
||||
return id
|
||||
}
|
||||
const text = (id: string): void => database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-execution-stats-'))
|
||||
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
database.initialize(directory)
|
||||
projectId = database.listProjects()[0]!.id
|
||||
conversationId = randomUUID()
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
at(0)
|
||||
})
|
||||
afterEach(async () => {
|
||||
database.close()
|
||||
vi.useRealTimers()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('sums hidden replies without round idle, counts approval/tool time, and scopes projects', () => {
|
||||
const first = request()
|
||||
text(first)
|
||||
at(2)
|
||||
database.updateTaskStatus(first, 'waiting_approval')
|
||||
at(8)
|
||||
database.updateTaskStatus(first, 'running')
|
||||
at(10)
|
||||
database.updateTaskStatus(first, 'completed')
|
||||
at(100)
|
||||
const second = request()
|
||||
text(second)
|
||||
at(105)
|
||||
database.updateTaskStatus(second, 'failed')
|
||||
const anotherConversation = request({ conversationId: randomUUID() })
|
||||
text(anotherConversation)
|
||||
at(108)
|
||||
database.updateTaskStatus(anotherConversation, 'cancelled')
|
||||
const unrelated = request({ projectId: undefined, conversationId: randomUUID() })
|
||||
text(unrelated)
|
||||
at(120)
|
||||
database.updateTaskStatus(unrelated, 'completed')
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.getExecutionStats({ conversationId })).toEqual({
|
||||
durationMs: 15000, requestCount: 2, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: epoch + 120000, taskDurations: []
|
||||
})
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 18000, requestCount: 3 })
|
||||
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
|
||||
})
|
||||
|
||||
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(schedule.taskId)
|
||||
const child = request({ origin: 'subagent', parentTaskId: schedule.taskId })
|
||||
text(child)
|
||||
const compact = request({ title: 'Compressed context' })
|
||||
const actual = request({ conversationId: schedule.conversationId, parentTaskId: schedule.taskId })
|
||||
text(actual)
|
||||
at(10)
|
||||
for (const id of [schedule.taskId, child, compact, actual]) database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('uses message request identity when a reply has no runtime events and does not use message timestamps', () => {
|
||||
const id = request()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Chat', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: '', state: 'error', createdAt: epoch - 86400000 }]
|
||||
}])
|
||||
// Only persisted request links are evidence; local snapshot saves do not supply them.
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE conversation_id = ?').run(id, conversationId)
|
||||
raw.close()
|
||||
at(3)
|
||||
database.updateTaskStatus(id, 'failed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('keeps only evidenced time for open and restart-interrupted replies', () => {
|
||||
const id = request()
|
||||
at(5)
|
||||
text(id)
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 100000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
database.close()
|
||||
at(86400)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
// A later resumed segment cannot include the shutdown/restart gap.
|
||||
at(86500)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(86510)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('segments repeated runs and pauses without using the final completed_at span', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(5)
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
at(100)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(107)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
at(200)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(203)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('does not substitute remote replay receipt time or unknown starts for actual execution time', () => {
|
||||
const remote = request()
|
||||
at(86400)
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: remote, type: 'done' } })
|
||||
database.updateTaskStatus(remote, 'completed')
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
at(86410)
|
||||
database.updateTaskStatus(unknown, 'completed')
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 2, incompleteRequestCount: 2 })
|
||||
})
|
||||
|
||||
it('includes all retained requests beyond list pagination', () => {
|
||||
for (let index = 0; index < 105; index++) {
|
||||
at(index * 10)
|
||||
const id = request()
|
||||
text(id)
|
||||
at(index * 10 + 1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
}
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 105000, requestCount: 105, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('groups real schedule request IDs into stable cards across runs without parent lifecycle time', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const idle = database.createSchedule({ projectId, conversationId: schedule.conversationId, title: 'Idle', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch + 86400000).toISOString() })
|
||||
for (const start of [100, 1000]) {
|
||||
at(start)
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeConversationUserQueueItem(queued.id)
|
||||
// Production agentRun uses scheduleRunId as requestId, without parentTaskId.
|
||||
const id = queued.scheduleRunId!
|
||||
request({ id, conversationId: schedule.conversationId })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(id)
|
||||
at(start + 10)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.completeTaskScheduleRun(id)
|
||||
}
|
||||
const live = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
request({ id: live.scheduleRunId!, conversationId: schedule.conversationId })
|
||||
text(live.scheduleRunId!)
|
||||
at(1025)
|
||||
const stats = database.getExecutionStats({ projectId }, new Set([live.scheduleRunId!]))
|
||||
expect(stats).toMatchObject({ durationMs: 35000, requestCount: 3, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(stats.taskDurations).toEqual(expect.arrayContaining([
|
||||
{ id: schedule.taskId, durationMs: 35000, incompleteRequestCount: 0 },
|
||||
{ id: idle.taskId, durationMs: 0, incompleteRequestCount: 0 }
|
||||
]))
|
||||
expect(stats.taskDurations.some(item => item.id === live.scheduleRunId)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])('retains schedule execution totals after plan deletion (repair old link: %s)', (oldLink) => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Removable plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
at(100)
|
||||
const run = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
const task = database.createTask({ id: run.scheduleRunId!, projectId, conversationId: schedule.conversationId, title: 'Run', instructions: 'Reply', workMode: 'ask', visible: false })
|
||||
expect(task.parentTaskId).toBe(schedule.taskId)
|
||||
text(task.id)
|
||||
at(110)
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
database.completeTaskScheduleRun(task.id)
|
||||
if (oldLink) {
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET parent_task_id = NULL WHERE id = ?').run(task.id)
|
||||
raw.close()
|
||||
}
|
||||
const before = database.getExecutionStats({ projectId })
|
||||
at(10000)
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId })).toEqual({ ...before, asOf: epoch + 10000000 })
|
||||
expect(before.taskDurations).toEqual([{ id: schedule.taskId, durationMs: 10000, incompleteRequestCount: 0 }])
|
||||
database.close()
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual(before.taskDurations)
|
||||
})
|
||||
|
||||
it('does not claim a complete zero for an already removed plan with no retained execution relation', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old removed plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('recognizes runtime status-only replies but not maintenance statuses at restart', () => {
|
||||
const id = request()
|
||||
const maintenance = request({ title: 'Maintenance' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Processing request' })
|
||||
database.updateTaskStatus(maintenance, 'running')
|
||||
database.close()
|
||||
at(10000)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('marks SSH channel timing unknown before the first remote provenance and keeps that result on replay', () => {
|
||||
const project = database.createSshProject({
|
||||
project: { name: 'SSH project', description: '', rootPath: '/srv/workspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
|
||||
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/workspace' },
|
||||
assertCurrent: () => {}
|
||||
})
|
||||
const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'wecom', accountId: 'account', externalConversationId: 'external', conversationType: 'direct', title: 'SSH channel', accountDisplay: 'Account' })
|
||||
const id = request({ projectId: project.id, conversationId: conversation.id, origin: 'delegation' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Remote Agent is processing' })
|
||||
const expected = { durationMs: 0, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 1 }
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
at(100)
|
||||
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: id, type: 'text', delta: 'Reply' } })
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId: conversation.id })).toMatchObject({ ...expected, activeRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('marks retained assistant replies without request history incomplete and ignores nested subagent content', () => {
|
||||
const known = request()
|
||||
text(known)
|
||||
at(2)
|
||||
database.updateTaskStatus(known, 'completed')
|
||||
const child = request({ origin: 'subagent', parentTaskId: known })
|
||||
text(child)
|
||||
database.updateTaskStatus(child, 'completed')
|
||||
const childMessage = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Retained', updatedAt: epoch },
|
||||
messages: [
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Known reply', state: 'complete', createdAt: epoch, subagents: [] },
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Older reply without a task', state: 'complete', createdAt: epoch },
|
||||
{ id: childMessage, role: 'assistant', content: 'Child reply', state: 'complete', createdAt: epoch }
|
||||
]
|
||||
}])
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE id = ?').run(child, childMessage)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('flags missing schedule execution once even when its assistant message is retained', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Lost run', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeScheduleRun(queued.scheduleRunId!, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Lost run', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained result', state: 'complete', createdAt: epoch, task: { id: schedule.taskId, title: 'Lost run' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({
|
||||
durationMs: 0, requestCount: 1, incompleteRequestCount: 1,
|
||||
taskDurations: [{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot make a remote or missing-start request complete just because it has a live lease', () => {
|
||||
const remote = request()
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: remote, type: 'text', delta: 'Remote' } })
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([remote, unknown]))).toMatchObject({ durationMs: 0, incompleteRequestCount: 2, activeRequestCount: 2 })
|
||||
})
|
||||
|
||||
it('counts confirmed live replies before their first event without counting maintenance leases', () => {
|
||||
const id = request()
|
||||
request({ title: 'Context compression' })
|
||||
at(10)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('reports wholly missing reply history and task attribution without using message timestamps', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old task', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Old conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Old reply', state: 'complete', createdAt: epoch - 100000, task: { id: schedule.taskId, title: 'Old task' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ conversationId: schedule.conversationId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('does not let request coverage in another conversation conceal missing replies', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: randomUUID(), projectId, title: 'Other conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Missing history', state: 'complete', createdAt: epoch }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 1000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。'],
|
||||
['en-US', 'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.']
|
||||
])('excludes the persisted default greeting but retains unknown reply history (%s)', async (locale, greeting) => {
|
||||
expect(await readFile(join(process.cwd(), 'src/renderer/src/i18n/locales', locale, 'app.ts'), 'utf8')).toContain(greeting)
|
||||
const header = { id: conversationId, projectId, title: 'New conversation', updatedAt: epoch }
|
||||
const welcome = { id: randomUUID(), role: 'assistant' as const, content: greeting, state: 'complete' as const, createdAt: epoch }
|
||||
database.saveLocalConversations([{ header, messages: [welcome] }])
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 0, incompleteRequestCount: 0 })
|
||||
database.saveLocalConversations([{ header, messages: [welcome,
|
||||
{ id: randomUUID(), role: 'user', content: 'Question', state: 'complete', createdAt: epoch + 1 },
|
||||
{ id: randomUUID(), role: 'assistant', content: greeting, state: 'complete', createdAt: epoch + 2 }
|
||||
] }])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('seeks interruption boundaries without parsing large tool/delta payloads on repeated scope queries', () => {
|
||||
const id = request()
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
|
||||
try {
|
||||
const insert = raw.prepare('INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)')
|
||||
const payload = JSON.stringify({ requestId: id, output: 'x'.repeat(32768) })
|
||||
raw.exec('BEGIN')
|
||||
for (let index = 0; index < 3000; index++) {
|
||||
insert.run(id, index % 2 ? 'text' : 'tool', payload, new Date(epoch + 5000).toISOString())
|
||||
}
|
||||
raw.exec('COMMIT')
|
||||
at(100)
|
||||
database.updateTaskStatus(id, 'interrupted')
|
||||
const baseline = raw.prepare("SELECT SUM(length(json_extract(payload_json, '$.requestId'))) FROM task_events WHERE task_id = ?")
|
||||
const baselineStart = performance.now()
|
||||
baseline.get(id)
|
||||
baseline.get(id)
|
||||
const baselineMs = performance.now() - baselineStart
|
||||
// One visible-panel tick reads both scopes; repeat to exercise warm polling.
|
||||
const start = performance.now()
|
||||
for (let iteration = 0; iteration < 5; iteration++) {
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
}
|
||||
const pairMs = (performance.now() - start) / 5
|
||||
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
|
||||
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
|
||||
for (const alias of ['proof', 'remote', 'tail', 'previous', 'e']) {
|
||||
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
|
||||
}
|
||||
// Malformed bodies make accidental JSON extraction fail deterministically.
|
||||
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
console.info(`execution stats: 3000 events, ${Math.round(payload.length * 3000 / 1024 / 1024)} MiB payload; old JSON extraction pair ${baselineMs.toFixed(1)} ms; scalar scope pair ${pairMs.toFixed(1)} ms`)
|
||||
} finally {
|
||||
prepare.mockRestore()
|
||||
raw.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
database.close()
|
||||
|
||||
const raw = new DatabaseSync(path)
|
||||
raw.exec('PRAGMA user_version = 2')
|
||||
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 2')
|
||||
raw.close()
|
||||
|
||||
const migrated = new AssistantDatabase(path)
|
||||
@@ -128,7 +128,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
'DELETE FROM heartbeat_config_projects WHERE config_id = ?'
|
||||
)
|
||||
.run(config.id)
|
||||
raw.exec('PRAGMA user_version = 20')
|
||||
raw.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 20')
|
||||
raw.close()
|
||||
|
||||
const migrated = new AssistantDatabase(path)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
const input = {
|
||||
title: 'Immediate task',
|
||||
prompt: 'Run exactly once',
|
||||
recurrence: 'once' as const,
|
||||
nextRunAt: '2020-01-01T00:00:00.000Z',
|
||||
runImmediately: true
|
||||
}
|
||||
|
||||
describe('immediate schedule creation', () => {
|
||||
it('commits one real queue occurrence and never rematerializes it on scheduler ticks', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const schedule = database.createSchedule(input)
|
||||
expect(schedule.enabled).toBe(false)
|
||||
expect(schedule).not.toHaveProperty('runImmediately')
|
||||
const [item] = database.listConversationQueueItems(schedule.conversationId)
|
||||
expect(item).toMatchObject({ scheduleId: schedule.id, taskId: schedule.taskId })
|
||||
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('queued')
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
const claimed = database.claimConversationQueueItem(schedule.conversationId)
|
||||
expect(claimed).toMatchObject({ source: 'schedule', runId: item!.id })
|
||||
expect(database.claimConversationQueueItem(schedule.conversationId)).toBeUndefined()
|
||||
database.completeScheduleRun(item!.id, 'completed')
|
||||
expect(database.listTasks().find(task => task.id === schedule.taskId)?.status).toBe('completed')
|
||||
expect(database.listSchedules()[0]).toMatchObject({ enabled: false, lastRunAt: expect.any(String) })
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back conversation, task and schedule when queue insertion fails, so retry creates only one', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-schedule-create-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
sql.exec(`CREATE TRIGGER reject_queue BEFORE INSERT ON conversation_queue_items
|
||||
BEGIN SELECT RAISE(ABORT, 'queue unavailable'); END;`)
|
||||
expect(() => database.createSchedule(input)).toThrow('queue unavailable')
|
||||
expect(database.listSchedules()).toEqual([])
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.listConversations()).toEqual([])
|
||||
expect(sql.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()?.count).toBe(0)
|
||||
sql.exec('DROP TRIGGER reject_queue')
|
||||
database.createSchedule(input)
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
expect(database.listTasks()).toHaveLength(1)
|
||||
expect(database.listConversations()).toHaveLength(1)
|
||||
expect(database.listConversationQueueItems()).toHaveLength(1)
|
||||
} finally {
|
||||
sql.close()
|
||||
database.close()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('consumes the immediate trigger even when cancelled and requires an explicit new run', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const schedule = database.createSchedule(input)
|
||||
expect(schedule.lastRunAt).toBe(schedule.nextRunAt)
|
||||
const [item] = database.listConversationQueueItems(schedule.conversationId)
|
||||
database.cancelConversationQueueItem(item!.id)
|
||||
expect(database.listSchedules()[0]).toMatchObject({
|
||||
enabled: false, lastRunAt: schedule.lastRunAt
|
||||
})
|
||||
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
|
||||
expect(() => database.setScheduleEnabled(schedule.id, true)).toThrow(
|
||||
'已执行的一次性计划不能恢复自动运行'
|
||||
)
|
||||
expect(database.getTask(schedule.taskId).status).toBe('cancelled')
|
||||
const tomorrow = new Date(Date.parse(schedule.nextRunAt) + 86_400_000)
|
||||
expect(database.queueDueSchedules(tomorrow)).toEqual([])
|
||||
|
||||
const retry = database.queueScheduleNow(schedule.id)
|
||||
expect(retry.id).not.toBe(item!.id)
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
expect(database.listConversationQueueItems()).toHaveLength(1)
|
||||
expect(database.getTask(schedule.taskId).status).toBe('queued')
|
||||
expect(database.claimConversationQueueItem(schedule.conversationId)).toMatchObject({
|
||||
runId: retry.id
|
||||
})
|
||||
database.completeScheduleRun(retry.id, 'completed')
|
||||
expect(database.getTask(schedule.taskId).status).toBe('completed')
|
||||
expect(database.queueDueSchedules(tomorrow)).toEqual([])
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['once', 'daily', 'weekly'] as const)('preserves pause and manual-run cancellation for a future %s schedule', (recurrence) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const nextRunAt = new Date(Date.now() + 86_400_000).toISOString()
|
||||
const schedule = database.createSchedule({
|
||||
...input, runImmediately: false, recurrence, nextRunAt
|
||||
})
|
||||
database.setScheduleEnabled(schedule.id, false)
|
||||
const manual = database.queueScheduleNow(schedule.id)
|
||||
database.cancelConversationQueueItem(manual.id)
|
||||
expect(database.listSchedules()[0]).toMatchObject({
|
||||
enabled: false, lastRunAt: undefined, nextRunAt
|
||||
})
|
||||
expect(database.queueDueSchedules(new Date(nextRunAt))).toEqual([])
|
||||
database.setScheduleEnabled(schedule.id, true)
|
||||
expect(database.queueDueSchedules(new Date(nextRunAt))).toHaveLength(1)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps scheduled creation enabled and rejects recurring immediate requests', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
expect(() => database.createSchedule({ ...input, recurrence: 'daily' })).toThrow('立即执行只支持单次任务')
|
||||
const schedule = database.createSchedule({ ...input, runImmediately: false })
|
||||
expect(schedule.enabled).toBe(true)
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
expect(database.queueDueSchedules()).toHaveLength(1)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import {
|
||||
documentOcrFailureSchema,
|
||||
@@ -59,7 +60,7 @@ export class DocumentOcrBroker {
|
||||
1_000
|
||||
)
|
||||
)
|
||||
return new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
return localInferenceService.run('ocr', '文档解析', () => new Promise<DocumentOcrResult>((resolve, reject) => {
|
||||
const onAbort = (): void =>
|
||||
this.cancelRequest(request.requestId, 'OCR 解析已取消')
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
@@ -78,7 +79,7 @@ export class DocumentOcrBroker {
|
||||
return
|
||||
}
|
||||
this.dispatchNext()
|
||||
})
|
||||
}), { model: request.modelId, cancelUnavailableReason: '现有 OCR 取消会终止共享 Worker;管理页不将服务释放用作单任务取消。' })
|
||||
}
|
||||
|
||||
respond(input: unknown): void {
|
||||
|
||||
@@ -96,6 +96,23 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('GoodBuddyConfigService', () => {
|
||||
it('publishes persisted application changes from config apply through the settings event', async () => {
|
||||
const { application, service, workspace } = await createHarness()
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = application.onChanged(changed)
|
||||
const current = await application.get()
|
||||
const applicationNavigation = { ...current.applicationNavigation, order: [...current.applicationNavigation.order].reverse() }
|
||||
const plan = await service.plan('settings-sync', workspace, {
|
||||
operations: [{ operation: 'application.update', updates: { applicationNavigation } }],
|
||||
})
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
await service.apply('settings-sync', { planId: plan.planId }, new AbortController().signal, async () => true)
|
||||
expect(changed).toHaveBeenCalledExactlyOnceWith(await application.get())
|
||||
expect(changed.mock.calls[0]![0].applicationNavigation).toEqual(applicationNavigation)
|
||||
unsubscribe()
|
||||
await application.update({ localInferenceEnabled: false })
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('returns only sanitized configuration and publishes common examples', async () => {
|
||||
const { service, capabilities } = await createHarness()
|
||||
await capabilities.saveMcpServer(undefined, {
|
||||
|
||||
+67
-53
@@ -1,3 +1,5 @@
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -45,10 +47,7 @@ import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
|
||||
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
|
||||
import type { EmbeddingProvider } from './knowledge/types'
|
||||
import { EmbeddingModelManager } from './knowledge/embedding-model-manager'
|
||||
import {
|
||||
EmbeddingInferenceBroker,
|
||||
type EmbeddingInferenceTransport
|
||||
} from './knowledge/embedding-inference-broker'
|
||||
import { EmbeddingInferenceBroker } from './knowledge/embedding-inference-broker'
|
||||
import { CohereRerankClient } from './knowledge/cohere-rerank-client'
|
||||
import { RuntimeSettingsStore } from './runtime-settings-store'
|
||||
import type { ResolvedRuntimeSettings } from './runtime-settings-store'
|
||||
@@ -220,6 +219,7 @@ let documentOcrBroker: DocumentOcrBroker | undefined
|
||||
let documentOcrModelManager: DocumentOcrModelManager | undefined
|
||||
let embeddingModelManager: EmbeddingModelManager | undefined
|
||||
const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
|
||||
const sharedEmbeddingBrokers = new Map<string, { broker: EmbeddingInferenceBroker; users: number; unregister: () => void }>()
|
||||
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
|
||||
let remoteAgentServices: RemoteAgentServices | undefined
|
||||
@@ -234,47 +234,6 @@ type ManagedEmbeddingProvider = EmbeddingProvider & {
|
||||
dispose?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function createEmbeddingUtilityTransport(
|
||||
modulePath: string,
|
||||
modelDirectory: string
|
||||
): EmbeddingInferenceTransport {
|
||||
const child = utilityProcess.fork(
|
||||
modulePath,
|
||||
[modelDirectory],
|
||||
{
|
||||
serviceName: 'GoodBuddy Embedding Inference',
|
||||
stdio: 'pipe',
|
||||
allowLoadingUnsignedLibraries: false
|
||||
}
|
||||
)
|
||||
return {
|
||||
postMessage: (message) => child.postMessage(message),
|
||||
onMessage: (listener) => {
|
||||
child.on('message', listener)
|
||||
return () => child.removeListener('message', listener)
|
||||
},
|
||||
onClose: (listener) => {
|
||||
let closed = false
|
||||
const notify = (): void => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
closed = true
|
||||
listener()
|
||||
}
|
||||
child.on('exit', notify)
|
||||
child.on('error', notify)
|
||||
return () => {
|
||||
child.removeListener('exit', notify)
|
||||
child.removeListener('error', notify)
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmbeddingProvider(
|
||||
settings: ResolvedRuntimeSettings,
|
||||
manager: EmbeddingModelManager,
|
||||
@@ -320,14 +279,38 @@ async function createEmbeddingProvider(
|
||||
if (!tokenizerDigest || !modelDigest) {
|
||||
throw new Error('内置向量模型安装清单不完整')
|
||||
}
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
createTransport: () =>
|
||||
createEmbeddingUtilityTransport(
|
||||
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
|
||||
modelDirectory
|
||||
)
|
||||
})
|
||||
embeddingBrokers.add(broker)
|
||||
let shared = sharedEmbeddingBrokers.get(modelDirectory)
|
||||
if (!shared) {
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
createTransport: () =>
|
||||
createEmbeddingUtilityTransport(
|
||||
join(mainModuleDirectory, 'embedding-inference-bootstrap.js'),
|
||||
modelDirectory
|
||||
)
|
||||
})
|
||||
embeddingBrokers.add(broker)
|
||||
const unregister = localInferenceService.register('embedding', {
|
||||
snapshot: () => ({
|
||||
id: 'embedding', name: '向量生成', engine: 'Granite / ONNX',
|
||||
ownership: 'managed-process', state: broker.getManagementState(), model: model.id,
|
||||
error: broker.getManagementError(),
|
||||
resources: broker.getResources(),
|
||||
detail: 'GoodBuddy 托管进程,由知识检索与索引共享。进程启动后模型在首个请求时加载;资源指标为整个向量服务进程占用。停止会中断使用方任务,启动不重放任务。',
|
||||
actions: broker.getManagementState() === 'stopped' || broker.getManagementState() === 'idle' || broker.getManagementState() === 'error'
|
||||
? ['start'] : broker.getManagementState() === 'running' ? ['stop', 'restart'] : []
|
||||
}),
|
||||
act: async (action) => {
|
||||
if (action !== 'start') await broker.stop()
|
||||
if (action !== 'stop') await broker.start()
|
||||
}
|
||||
})
|
||||
shared = { broker, users: 0, unregister }
|
||||
sharedEmbeddingBrokers.set(modelDirectory, shared)
|
||||
}
|
||||
shared.users += 1
|
||||
const sharedBroker = shared
|
||||
const broker = shared.broker
|
||||
let disposed = false
|
||||
return {
|
||||
provider: 'builtin',
|
||||
model: model.id,
|
||||
@@ -354,6 +337,12 @@ async function createEmbeddingProvider(
|
||||
embedDocuments: (texts, signal) =>
|
||||
broker.embed(texts, 'document', signal),
|
||||
dispose: async () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
sharedBroker.users -= 1
|
||||
if (sharedBroker.users > 0) return
|
||||
sharedEmbeddingBrokers.delete(modelDirectory)
|
||||
sharedBroker.unregister()
|
||||
try {
|
||||
await broker.shutdown()
|
||||
} finally {
|
||||
@@ -803,6 +792,19 @@ if (hasSingleInstanceLock) {
|
||||
documentOcrModelManager,
|
||||
documentOcrBroker
|
||||
)
|
||||
localInferenceService.register('ocr', {
|
||||
snapshot: async () => {
|
||||
const snapshot = await documentParsingService.snapshot()
|
||||
const model = snapshot.status.localOcr
|
||||
return {
|
||||
id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker',
|
||||
resources: { scope: 'unavailable', reason: 'Web Worker 与界面共享渲染进程,无法独立统计 CPU / 内存' },
|
||||
state: model.available ? 'unknown' : 'unavailable', model: model.id,
|
||||
detail: `${model.detail}。模型由 Renderer Worker 持有,文档解析按需加载,空闲 60 秒自动释放;仅允许无活动任务时手动释放。`,
|
||||
actions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
const versionChecker = new VersionChecker({
|
||||
fetch: globalThis.fetch,
|
||||
currentVersion: app.getVersion(),
|
||||
@@ -818,6 +820,18 @@ if (hasSingleInstanceLock) {
|
||||
const speechTranscriptionService = new SpeechTranscriptionService(
|
||||
speechModelManager
|
||||
)
|
||||
localInferenceService.register('asr', {
|
||||
snapshot: async () => {
|
||||
const model = await speechModelManager.getSelectedRuntimeModel()
|
||||
return {
|
||||
id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker',
|
||||
resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' },
|
||||
state: model ? 'unknown' : 'unavailable', model: model?.id,
|
||||
detail: model ? '每个识别请求独立加载模型,完成后释放;支持取消单次识别,没有常驻服务可启停。' : '请在设置中安装并选择本地语音模型。',
|
||||
actions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
browserService = new BrowserService({ parentWindow: mainWindow })
|
||||
const bundledRuntimePaths = resolveBundledRuntimePaths({
|
||||
appPath: app.getAppPath(),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProcessMetric } from 'electron'
|
||||
import { createInferenceProcessSampler } from './inference-process-resources'
|
||||
|
||||
describe('inference process resources', () => {
|
||||
it('measures only the utility PID, warms CPU and converts KiB to bytes', () => {
|
||||
let time = 0
|
||||
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { cumulativeCPUUsage: 2, percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 2048, peakWorkingSetSize: 2048 } }
|
||||
const sample = createInferenceProcessSampler(() => 42, () => [{ ...metric, pid: 99 }, metric], () => time)
|
||||
expect(sample()).toMatchObject({ scope: 'service-process', pid: 42, workingSetBytes: 2097152, cpuUnavailableReason: '正在建立采样基线' })
|
||||
time = 5100
|
||||
metric.cpu.cumulativeCPUUsage = 9.65
|
||||
expect(sample()).toMatchObject({ cpuPercent: 150 })
|
||||
time = 21000
|
||||
expect(sample()).toMatchObject({ cpuUnavailableReason: '正在建立采样基线' })
|
||||
time = 26000
|
||||
metric.creationTime = 2
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
time = 31000
|
||||
metric.cpu.cumulativeCPUUsage = 0
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
})
|
||||
|
||||
it('clears baselines on exit, missing metrics and failure; preserves partial readings', () => {
|
||||
let pid: number | undefined = 42
|
||||
let time = 0
|
||||
let fail = false
|
||||
const metric: ProcessMetric = { pid: 42, type: 'Utility', creationTime: 1, cpu: { percentCPUUsage: 0, idleWakeupsPerSecond: 0 }, memory: { workingSetSize: 1024, peakWorkingSetSize: 1024 } }
|
||||
const sample = createInferenceProcessSampler(() => pid, () => { if (fail) throw new Error('sampling'); return [metric] }, () => time += 2000)
|
||||
expect(sample()).toMatchObject({ workingSetBytes: 1048576, cpuUnavailableReason: '系统未提供进程 CPU 时间' })
|
||||
metric.cpu.cumulativeCPUUsage = 1
|
||||
metric.memory.workingSetSize = NaN
|
||||
expect(sample()).toMatchObject({ memoryUnavailableReason: '系统未提供工作集内存' })
|
||||
fail = true
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '进程资源采样失败' })
|
||||
fail = false
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
pid = undefined
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '服务进程未运行' })
|
||||
pid = 43
|
||||
expect(sample()).toEqual({ scope: 'unavailable', reason: '等待进程资源数据' })
|
||||
metric.pid = 43
|
||||
expect(sample()).not.toHaveProperty('cpuPercent')
|
||||
metric.type = 'Tab'
|
||||
expect(sample()).toMatchObject({ scope: 'unavailable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ProcessMetric } from 'electron'
|
||||
import type { InferenceResources } from '../shared/local-inference-contracts'
|
||||
|
||||
// One sampler per transport: a replacement process never inherits the old baseline.
|
||||
export function createInferenceProcessSampler(
|
||||
getPid: () => number | undefined,
|
||||
getMetrics: () => ProcessMetric[],
|
||||
now: () => number = () => performance.now()
|
||||
): () => InferenceResources {
|
||||
let previous: { pid: number; creationTime: number; time: number; cpu: number } | undefined
|
||||
return () => {
|
||||
const pid = getPid()
|
||||
let metric: ProcessMetric | undefined
|
||||
try {
|
||||
if (pid !== undefined) metric = getMetrics().find((entry) => entry.pid === pid && entry.type === 'Utility')
|
||||
} catch {
|
||||
previous = undefined
|
||||
return { scope: 'unavailable', reason: '进程资源采样失败' }
|
||||
}
|
||||
if (!metric) {
|
||||
previous = undefined
|
||||
return { scope: 'unavailable', reason: pid === undefined ? '服务进程未运行' : '等待进程资源数据' }
|
||||
}
|
||||
const time = now()
|
||||
const cpu = metric.cpu.cumulativeCPUUsage
|
||||
const resources: InferenceResources = {
|
||||
scope: 'service-process', pid: metric.pid, sampledAt: Date.now(),
|
||||
cpuUnavailableReason: '正在建立采样基线'
|
||||
}
|
||||
// Electron reports working set in KiB. Do not replace absent readings with zero.
|
||||
const memory = metric.memory?.workingSetSize
|
||||
if (Number.isFinite(memory) && memory >= 0) resources.workingSetBytes = memory * 1024
|
||||
else resources.memoryUnavailableReason = '系统未提供工作集内存'
|
||||
if (cpu !== undefined && Number.isFinite(cpu) && cpu >= 0) {
|
||||
if (previous && previous.pid === metric.pid && previous.creationTime === metric.creationTime &&
|
||||
time > previous.time && time - previous.time <= 15000 && cpu >= previous.cpu) {
|
||||
// Single-core capacity is 100%; multithreaded inference may exceed 100%.
|
||||
resources.cpuPercent = (cpu - previous.cpu) * 100_000 / (time - previous.time)
|
||||
resources.cpuUnavailableReason = undefined
|
||||
}
|
||||
previous = { pid: metric.pid, creationTime: metric.creationTime, time, cpu }
|
||||
} else {
|
||||
previous = undefined
|
||||
resources.cpuUnavailableReason = '系统未提供进程 CPU 时间'
|
||||
}
|
||||
return resources
|
||||
}
|
||||
}
|
||||
+158
-5
@@ -12,6 +12,7 @@ import { join } from 'node:path'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
ConversationSnapshot,
|
||||
ConversationQueueItem
|
||||
} from '../shared/assistant-contracts'
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
runtimeSettingsInputSchema,
|
||||
type AgentEvent,
|
||||
type BrowserLiveState,
|
||||
type BrowserTabId
|
||||
type BrowserTabId,
|
||||
type ConversationQueueDispatch
|
||||
} from '../shared/contracts'
|
||||
import type {
|
||||
SshHostAgentConnectionStatus
|
||||
@@ -1056,6 +1058,8 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
const versionChecker = {
|
||||
check: vi.fn(async () => result)
|
||||
}
|
||||
const unsubscribe = vi.fn()
|
||||
const onChanged = vi.fn<(listener: (settings: unknown) => void) => () => void>(() => unsubscribe)
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
@@ -1076,10 +1080,14 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ get: getApplicationSettings } as never,
|
||||
{ get: getApplicationSettings, onChanged } as never,
|
||||
versionChecker as never
|
||||
)
|
||||
|
||||
const changedSettings = { updateSource: 'github' }
|
||||
onChanged.mock.calls[0]
|
||||
expect(webContents.send).toHaveBeenCalledWith(ipcChannels.applicationSettingsChanged, changedSettings)
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.versionCheck)?.(event)
|
||||
).resolves.toEqual(result)
|
||||
@@ -1111,6 +1119,7 @@ describe('registerIpcHandlers update source routing', () => {
|
||||
)
|
||||
|
||||
await dispose()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1177,6 +1186,7 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
snapshot: vi.fn(async () => ocrSnapshot)
|
||||
}
|
||||
const applicationSettingsStore = {
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
modelDownloadSource: 'hugging-face'
|
||||
}))
|
||||
@@ -1781,7 +1791,8 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
undefined, // subagentService
|
||||
undefined, // channelSettingsStore
|
||||
{
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true })),
|
||||
onChanged: vi.fn()
|
||||
} as never, // applicationSettingsStore
|
||||
undefined, // versionChecker
|
||||
undefined, // speechModelManager
|
||||
@@ -2212,6 +2223,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
const applicationSettingsStore = {
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: false }))
|
||||
}
|
||||
const sshHostService = {
|
||||
@@ -4235,6 +4247,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => {
|
||||
throw new Error('settings unavailable')
|
||||
})
|
||||
@@ -4323,6 +4336,7 @@ describe('registerIpcHandlers workspace files', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
onChanged: vi.fn(),
|
||||
get: vi.fn(async () => ({ remoteProjectsEnabled: true }))
|
||||
} as never
|
||||
)
|
||||
@@ -4363,6 +4377,42 @@ describe('registerIpcHandlers token usage', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates execution statistics scope and sender before reading evidence', async () => {
|
||||
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
|
||||
const assistantDatabase = {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getExecutionStats: vi.fn(() => summary)
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
|
||||
}
|
||||
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
|
||||
const dispose = registerIpcHandlers(
|
||||
window as never, { capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space', {} as never, {} as never,
|
||||
{ clear: vi.fn() } as never, {} as never, assistantDatabase as never,
|
||||
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
|
||||
)
|
||||
try {
|
||||
const handler = electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const id = '00000000-0000-4000-8000-000000000301'
|
||||
for (const scope of [{ conversationId: id }, { projectId: id }]) {
|
||||
expect(handler(event, scope)).toBe(summary)
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
|
||||
}
|
||||
expect(() => handler(event, {})).toThrow()
|
||||
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
|
||||
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the database token summary to a trusted renderer', async () => {
|
||||
const summary = {
|
||||
totals: {
|
||||
@@ -5273,7 +5323,8 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
subagentService as never,
|
||||
undefined,
|
||||
{
|
||||
get: getApplicationSettings
|
||||
get: getApplicationSettings,
|
||||
onChanged: vi.fn()
|
||||
} as never,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -5516,7 +5567,7 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
title: '每周汇总',
|
||||
prompt: '汇总本周进展',
|
||||
recurrence: 'weekly' as const,
|
||||
nextRunAt: '2026-08-21T09:00:00.000Z'
|
||||
nextRunAt: new Date(Date.now() + 60_000).toISOString()
|
||||
}
|
||||
|
||||
await electronMocks.handlers.get(
|
||||
@@ -5532,6 +5583,68 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed'] as const)('creates an immediate task into the production queue and dispatches exactly one ordinary run (%s)', async (status) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const conversationId = crypto.randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
|
||||
messages: []
|
||||
}])
|
||||
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
||||
if (status === 'failed') {
|
||||
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
|
||||
return
|
||||
}
|
||||
yield { type: 'text' as const, requestId: request.requestId, delta: 'Once' }
|
||||
yield { type: 'done' as const, requestId: request.requestId }
|
||||
})
|
||||
const harness = createHarness(
|
||||
{ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
|
||||
undefined, 'always', undefined, false, undefined, undefined, undefined,
|
||||
false, undefined, undefined, undefined, undefined, database
|
||||
)
|
||||
const event = trustedEvent(harness.webContents)
|
||||
try {
|
||||
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
||||
const create = electronMocks.handlers.get(ipcChannels.schedulesCreate)!
|
||||
const input = {
|
||||
conversationId, title: 'Immediate', prompt: 'Run once', recurrence: 'once',
|
||||
nextRunAt: '2020-01-01T00:00:00.000Z'
|
||||
}
|
||||
expect(() => create(event, input)).toThrow('首次运行时间必须晚于当前时间')
|
||||
expect(database.listSchedules()).toEqual([])
|
||||
const schedule = await create(event, { ...input, runImmediately: true }) as AssistantSchedule
|
||||
expect(schedule.enabled).toBe(false)
|
||||
const dispatches = () => harness.webContents.send.mock.calls.filter(
|
||||
([channel]) => channel === ipcChannels.conversationQueueDispatch
|
||||
)
|
||||
expect(dispatches()).toHaveLength(1)
|
||||
const dispatch = dispatches()[0]![1] as ConversationQueueDispatch
|
||||
expect(dispatch).toMatchObject({ scheduled: true, item: { taskId: schedule.taskId } })
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
await harness.handler!(event, {
|
||||
requestId: dispatch.item.id,
|
||||
conversationId,
|
||||
queueItemId: dispatch.item.id,
|
||||
prompt: input.prompt,
|
||||
workMode: 'ask',
|
||||
currentUserMessageId: crypto.randomUUID(),
|
||||
currentAssistantMessageId: crypto.randomUUID()
|
||||
})
|
||||
await vi.waitFor(() => expect(database.getTask(schedule.taskId).status).toBe(status))
|
||||
await electronMocks.handlers.get(ipcChannels.conversationQueueReady)!(event, conversationId)
|
||||
expect(database.queueDueSchedules(new Date('2099-01-01T00:00:00Z'))).toEqual([])
|
||||
expect(dispatches()).toHaveLength(1)
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispatches scheduled messages through ordinary agent runs with current history and tools', async () => {
|
||||
const fullOutput = `${'x'.repeat(1_000_001)} scheduled tail`
|
||||
const taskId = '00000000-0000-4000-8000-000000000701'
|
||||
@@ -7814,6 +7927,46 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('retains user-enabled knowledge scopes when other applications are disabled', async () => {
|
||||
const libraryId = '11111111-1111-4111-8111-111111111111'
|
||||
let finish: (() => void) | undefined
|
||||
const held = new Promise<void>(resolve => { finish = resolve })
|
||||
const run = vi.fn(async function* (request: { requestId: string; knowledgeLibraryIds: string[] }) {
|
||||
if (request.requestId.endsWith('21')) await held
|
||||
yield { requestId: request.requestId, type: 'done' }
|
||||
})
|
||||
const retrieveMany = vi.fn(async () => [])
|
||||
const gateway = { grant: vi.fn(() => 'capability'), getAvailableToolNames: vi.fn(() => ['knowledge_search']), drainReferences: vi.fn(() => []), revoke: vi.fn() }
|
||||
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, supportsScopedDataTools: true, run }, undefined, 'always', undefined, false, undefined, {
|
||||
database: { listKnowledgeBases: vi.fn(() => [{ id: libraryId, name: 'Knowledge' }]) }, retrieveMany
|
||||
}, gateway)
|
||||
const request = { prompt: 'use saved knowledge', workMode: 'ask', knowledgeLibraryIds: [libraryId], knowledgeRetrievalMode: 'always' }
|
||||
try {
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'in-flight' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(retrieveMany).toHaveBeenCalledOnce()
|
||||
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: false, magicNotesEnabled: false })
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000022', conversationId: 'disabled' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
||||
expect(run.mock.calls[0]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(run.mock.calls[1]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(retrieveMany).toHaveBeenCalledTimes(2)
|
||||
expect(gateway.grant).toHaveBeenCalledTimes(2)
|
||||
expect(harness.assistantDatabase.updateTaskStatus).not.toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'cancelled')
|
||||
finish!()
|
||||
await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000021', 'completed'))
|
||||
harness.getApplicationSettings.mockResolvedValue({ localInferenceEnabled: true, magicNotesEnabled: false })
|
||||
await harness.handler?.(trustedEvent(harness.webContents), { ...request, requestId: '00000000-0000-4000-8000-000000000023', conversationId: 'restored' })
|
||||
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(3))
|
||||
expect(run.mock.calls[2]![0].knowledgeLibraryIds).toEqual([libraryId])
|
||||
expect(retrieveMany).toHaveBeenCalledTimes(3)
|
||||
expect(request.knowledgeLibraryIds).toEqual([libraryId])
|
||||
} finally {
|
||||
finish!()
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('grants read-only Magic Notes tools in Ask and write tools in Execute', async () => {
|
||||
const runtime = {
|
||||
runtimeId: 'model',
|
||||
|
||||
+62
-5
@@ -1,5 +1,7 @@
|
||||
import { externalKnowledgeInstanceInputSchema, externalKnowledgeInstanceEnabledInputSchema, externalKnowledgeInstanceSaveInputSchema, externalKnowledgeCatalogListInputSchema, externalKnowledgeCatalogGetInputSchema, externalKnowledgeBindingSaveInputSchema, externalKnowledgeBindingUpdateInputSchema, externalKnowledgeBindingTestInputSchema } from '../shared/external-knowledge-contracts'
|
||||
import { knowledgeReferenceKey, toKnowledgeReference } from '../shared/knowledge-reference'
|
||||
import { localInferenceService } from './local-inference-service'
|
||||
import { inferenceActionSchema, inferenceCancelSchema } from '../shared/local-inference-contracts'
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -200,6 +202,7 @@ import {
|
||||
} from '../shared/magic-notes-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
executionStatsInputSchema,
|
||||
activityHistorySnapshotSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotsSchema,
|
||||
@@ -1345,6 +1348,7 @@ export function registerIpcHandlers(
|
||||
): () => Promise<void> {
|
||||
type ActiveRequestLease = {
|
||||
controller: AbortController
|
||||
isReply: boolean
|
||||
conversationId: string
|
||||
detachOnApplicationExit: boolean
|
||||
teamMode?: boolean
|
||||
@@ -1375,10 +1379,12 @@ export function registerIpcHandlers(
|
||||
const leaseActiveRequest = (
|
||||
requestId: string,
|
||||
conversationId: string,
|
||||
controller: AbortController
|
||||
controller: AbortController,
|
||||
isReply = true
|
||||
): ActiveRequestLease => {
|
||||
const lease: ActiveRequestLease = {
|
||||
controller,
|
||||
isReply,
|
||||
conversationId,
|
||||
detachOnApplicationExit: false,
|
||||
release: (): void => {
|
||||
@@ -1580,6 +1586,11 @@ export function registerIpcHandlers(
|
||||
}
|
||||
window.on('maximize', notifyMaximizedChanged)
|
||||
window.on('unmaximize', notifyMaximizedChanged)
|
||||
const removeApplicationSettingsListener = applicationSettingsStore?.onChanged((settings) => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send(ipcChannels.applicationSettingsChanged, settings)
|
||||
}
|
||||
})
|
||||
const removeBrowserStateListener = browserControl?.onState((state) => {
|
||||
if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) {
|
||||
window.webContents.send(ipcChannels.browserState, state)
|
||||
@@ -5117,7 +5128,8 @@ export function registerIpcHandlers(
|
||||
const activeRequestLease = leaseActiveRequest(
|
||||
request.requestId,
|
||||
request.conversationId,
|
||||
controller
|
||||
controller,
|
||||
false
|
||||
)
|
||||
assistantDatabase.createTask({
|
||||
id: request.requestId,
|
||||
@@ -6480,6 +6492,29 @@ export function registerIpcHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.localInferenceGet, async (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
const snapshot = await localInferenceService.snapshot()
|
||||
const settings = await settingsStore.getPublicSettings()
|
||||
return {
|
||||
...snapshot,
|
||||
externalConnections: settings.embeddingConnections?.filter((connection) => connection.kind !== 'builtin')
|
||||
.map((connection) => ({ id: connection.id, name: connection.name, model: connection.modelName })) ?? []
|
||||
}
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceOpenSettings, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
window.webContents.send(ipcChannels.settingsOpen)
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceAct, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return trackExecution(localInferenceService.act(inferenceActionSchema.parse(input)))
|
||||
})
|
||||
registerHandler(ipcChannels.localInferenceCancel, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
localInferenceService.cancel(inferenceCancelSchema.parse(input).taskId)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.speechModelsGet, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
if (!speechModelManager) {
|
||||
@@ -7220,6 +7255,13 @@ export function registerIpcHandlers(
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.listTasks()
|
||||
})
|
||||
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getExecutionStats(
|
||||
executionStatsInputSchema.parse(input),
|
||||
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
|
||||
)
|
||||
})
|
||||
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const parsed = taskStatusRequestSchema.parse(input)
|
||||
@@ -7398,9 +7440,23 @@ export function registerIpcHandlers(
|
||||
|
||||
registerHandler(ipcChannels.schedulesCreate, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.createSchedule(
|
||||
scheduleCreateSchema.parse(input)
|
||||
)
|
||||
const value = scheduleCreateSchema.parse(input)
|
||||
if (value.runImmediately) {
|
||||
if (executionPaused || shuttingDown) {
|
||||
throw new Error('本地数据维护期间暂不接受新任务')
|
||||
}
|
||||
} else if (new Date(value.nextRunAt).getTime() <= Date.now()) {
|
||||
throw new Error('首次运行时间必须晚于当前时间。')
|
||||
}
|
||||
const schedule = assistantDatabase.createSchedule(value)
|
||||
if (value.runImmediately) {
|
||||
publishConversationQueueChange(schedule.conversationId)
|
||||
if (!isConversationExecuting(schedule.conversationId)) {
|
||||
readyConversationQueues.add(schedule.conversationId)
|
||||
void pumpConversationQueue(schedule.conversationId)
|
||||
}
|
||||
}
|
||||
return schedule
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
@@ -8737,6 +8793,7 @@ export function registerIpcHandlers(
|
||||
|
||||
return async () => {
|
||||
shuttingDown = true
|
||||
removeApplicationSettingsListener?.()
|
||||
removeLocalToolEnvironmentProgressListener?.()
|
||||
await localToolEnvironmentService?.dispose()
|
||||
removeBrowserStateListener?.()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import { EmbeddingInferenceWorker } from './embedding-inference-worker'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
EMBEDDING_INFERENCE_PROTOCOL,
|
||||
EMBEDDING_INFERENCE_PROTOCOL_VERSION,
|
||||
type EmbeddingInferenceRequest
|
||||
@@ -62,6 +65,255 @@ function embedRequest(transport: TestTransport): Extract<
|
||||
}
|
||||
|
||||
describe('EmbeddingInferenceBroker', () => {
|
||||
it('drops exited process resources and uses the replacement transport after restart', async () => {
|
||||
const first = new TestTransport()
|
||||
const second = new TestTransport()
|
||||
const resourceTransport = (transport: TestTransport, pid: number): EmbeddingInferenceTransport => Object.assign(transport, {
|
||||
getResources: () => ({ scope: 'service-process' as const, pid, sampledAt: 1, workingSetBytes: 1024 })
|
||||
})
|
||||
const factory = vi.fn().mockReturnValueOnce(resourceTransport(first, 42)).mockReturnValueOnce(resourceTransport(second, 43))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 42 })
|
||||
first.crash()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
await broker.start()
|
||||
expect(broker.getResources()).toMatchObject({ pid: 43 })
|
||||
await broker.stop()
|
||||
expect(broker.getResources()).toMatchObject({ scope: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps source-cancelled computation in stop impact until the worker returns', async () => {
|
||||
const transport = new TestTransport()
|
||||
let finish!: (vectors: number[][]) => void
|
||||
let workerSignal!: AbortSignal
|
||||
const engine = {
|
||||
tokenizer: { tokenize: vi.fn() },
|
||||
onnx: { run: vi.fn() },
|
||||
embed: vi.fn(({ signal }: { signal: AbortSignal }) => {
|
||||
workerSignal = signal
|
||||
return new Promise<number[][]>((resolve) => { finish = resolve })
|
||||
})
|
||||
}
|
||||
const worker = new EmbeddingInferenceWorker({
|
||||
modelDirectory: process.cwd(), engine,
|
||||
port: {
|
||||
postMessage: (message) => transport.receive(message),
|
||||
onMessage: (listener) => {
|
||||
const spy = vi.spyOn(transport, 'postMessage').mockImplementation((message) => {
|
||||
transport.sent.push(message)
|
||||
listener(message)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
}
|
||||
})
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const act = vi.fn(async () => undefined)
|
||||
const unregister = localInferenceService.register('embedding', {
|
||||
snapshot: () => ({ id: 'embedding', name: 'Embedding', engine: 'ONNX', detail: '', ownership: 'managed-process', state: 'running', actions: ['stop', 'restart'] }),
|
||||
act
|
||||
})
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['delayed computation'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(engine.embed).toHaveBeenCalledOnce())
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
expect(workerSignal.aborted).toBe(true)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
await expect(localInferenceService.act({ serviceId: 'embedding', action: 'restart', confirmedTaskIds: [] })).rejects.toThrow('任务已变化')
|
||||
localInferenceService.cancel(task.id)
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
expect(act).not.toHaveBeenCalled()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
finish([[1, 2]])
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
})
|
||||
await localInferenceService.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })
|
||||
expect(act).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
unregister()
|
||||
finish?.([[1, 2]])
|
||||
await worker.shutdown()
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['abort', 'acknowledgement'], ['abort', 'late result'], ['abort', 'process exit'],
|
||||
['timeout', 'acknowledgement'], ['timeout', 'late result'], ['timeout', 'process exit']
|
||||
] as const)('keeps execution cancelling after %s until %s', async (cause, completion) => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport, timeoutMs: 100 })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const pending = broker.embed(['cancelled work'], 'query', controller.signal)
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: cause === 'abort' ? 'AbortError' : 'TimeoutError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
if (cause === 'abort') controller.abort()
|
||||
else await vi.advanceTimersByTimeAsync(100)
|
||||
await rejection
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
expect(transport.sent.filter((message) => message.type === 'cancel')).toHaveLength(1)
|
||||
if (completion === 'process exit') transport.crash()
|
||||
else transport.receive(response(completion === 'late result'
|
||||
? { type: 'result', requestId: request.requestId, vectors: [[1, 2]] }
|
||||
: { type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({
|
||||
state: cause === 'abort' ? 'cancelled' : 'failed',
|
||||
error: cause === 'abort' ? 'Embedding inference was cancelled' : 'Embedding inference timed out',
|
||||
finishedAt: expect.any(Number)
|
||||
})
|
||||
} finally {
|
||||
await broker.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps rejected work active through an unconfirmed stop until process exit', async () => {
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockRejectedValue(new Error('exit not confirmed'))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['still computing'], 'query')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.waitFor(async () => {
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'failed', finishedAt: expect.any(Number) })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps cancelled requests within the in-flight bound until acknowledgement', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
const callers = Array.from({ length: EMBEDDING_INFERENCE_MAX_IN_FLIGHT }, () =>
|
||||
expect(broker.embed(['pending'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }))
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(EMBEDDING_INFERENCE_MAX_IN_FLIGHT))
|
||||
controller.abort()
|
||||
await Promise.all(callers)
|
||||
await expect(broker.embed(['over capacity'], 'query')).rejects.toMatchObject({ code: 'OVERLOADED' })
|
||||
for (const request of transport.sent.filter((message) => message.type === 'embed')) {
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
}
|
||||
const next = broker.embed(['capacity released'], 'query')
|
||||
await vi.waitFor(() => expect(transport.sent.at(-1)?.type).toBe('embed'))
|
||||
const request = transport.sent.at(-1)!
|
||||
if (request.type !== 'embed') throw new Error('No next embed request was sent')
|
||||
transport.receive(response({ type: 'result', requestId: request.requestId, vectors: [[1, 2]] }))
|
||||
await expect(next).resolves.toEqual([[1, 2]])
|
||||
} finally {
|
||||
await broker.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps execution active after shutdown grace and close timeout until process exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const transport = new TestTransport()
|
||||
transport.close.mockImplementation(() => new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error('exit not confirmed')), 5000)
|
||||
}))
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const rejection = expect(broker.embed(['still computing'], 'query', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
controller.abort()
|
||||
await rejection
|
||||
const shutdown = broker.shutdown()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await shutdown
|
||||
expect(transport.close).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
const activeTask = (await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)!
|
||||
expect(activeTask.state).toBe('cancelling')
|
||||
expect(activeTask.finishedAt).toBeUndefined()
|
||||
transport.crash()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)).toMatchObject({ state: 'cancelled', finishedAt: expect.any(Number) })
|
||||
} finally {
|
||||
transport.crash()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains a live transport and reports a failed stop instead of claiming stopped', async () => {
|
||||
const transport: EmbeddingInferenceTransport = {
|
||||
postMessage: vi.fn(), onMessage: () => () => undefined, onClose: () => () => undefined,
|
||||
close: vi.fn(async () => { throw new Error('exit not confirmed') })
|
||||
}
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
await broker.start()
|
||||
await expect(broker.stop()).rejects.toThrow('exit not confirmed')
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBe('exit not confirmed')
|
||||
})
|
||||
|
||||
it('reports startup failures and supports an explicit retry', async () => {
|
||||
const factory = vi.fn().mockRejectedValueOnce(new Error('spawn failed')).mockResolvedValue(new TestTransport())
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: factory })
|
||||
await expect(broker.start()).rejects.toThrow('START_FAILURE')
|
||||
expect(broker.getManagementState()).toBe('error')
|
||||
await broker.start()
|
||||
expect(broker.getManagementState()).toBe('running')
|
||||
expect(broker.getManagementError()).toBeUndefined()
|
||||
await broker.stop()
|
||||
})
|
||||
it('management cancellation waits for worker acknowledgement without closing shared transport', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => transport })
|
||||
const pending = broker.embed(['cancel this'], 'query')
|
||||
const rejection = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await vi.waitFor(() => expect(transport.sent).toHaveLength(1))
|
||||
const request = embedRequest(transport)
|
||||
const task = (await localInferenceService.snapshot()).tasks.find((entry) => entry.serviceId === 'embedding' && entry.finishedAt === undefined)!
|
||||
localInferenceService.cancel(task.id)
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelling')
|
||||
expect(transport.close).not.toHaveBeenCalled()
|
||||
transport.receive(response({ type: 'error', requestId: request.requestId, code: 'CANCELLED', message: 'cancelled' }))
|
||||
await rejection
|
||||
expect((await localInferenceService.snapshot()).tasks.find((entry) => entry.id === task.id)?.state).toBe('cancelled')
|
||||
await broker.stop()
|
||||
})
|
||||
|
||||
it('stops shared execution, rejects work until explicitly started, and never replays', async () => {
|
||||
const transports: TestTransport[] = []
|
||||
const broker = new EmbeddingInferenceBroker({ createTransport: () => { const transport = new TestTransport(); transports.push(transport); return transport } })
|
||||
const pending = broker.embed(['interrupted'], 'document')
|
||||
const rejection = expect(pending).rejects.toThrow('停止')
|
||||
await vi.waitFor(() => expect(transports[0]?.sent).toHaveLength(1))
|
||||
await broker.stop()
|
||||
await rejection
|
||||
expect(transports[0]!.close).toHaveBeenCalledOnce()
|
||||
await expect(broker.embed(['blocked'], 'query')).rejects.toMatchObject({ code: 'CLOSED' })
|
||||
await broker.start()
|
||||
expect(transports).toHaveLength(2)
|
||||
expect(transports[1]!.sent).toEqual([])
|
||||
await broker.stop()
|
||||
})
|
||||
it('uses UUID requests with roles and validates bounded results', async () => {
|
||||
const transport = new TestTransport()
|
||||
const broker = new EmbeddingInferenceBroker({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import type { InferenceResources } from '../../shared/local-inference-contracts'
|
||||
import {
|
||||
EMBEDDING_INFERENCE_MAX_IN_FLIGHT,
|
||||
embeddingInferenceProtocolBase,
|
||||
@@ -15,13 +17,13 @@ const MAX_TIMEOUT_MS = 300_000
|
||||
const DEFAULT_MAX_RESTARTS = 2
|
||||
const MAX_RESTARTS = 10
|
||||
const SHUTDOWN_GRACE_MS = 1_000
|
||||
const MAX_RETIRED_REQUESTS = 256
|
||||
|
||||
export interface EmbeddingInferenceTransport {
|
||||
getResources?(): InferenceResources
|
||||
postMessage(message: EmbeddingInferenceRequest): void
|
||||
onMessage(listener: (message: unknown) => void): () => void
|
||||
onClose(listener: () => void): () => void
|
||||
close(): void
|
||||
close(): void | Promise<void>
|
||||
}
|
||||
|
||||
export type EmbeddingInferenceTransportFactory = () =>
|
||||
@@ -42,6 +44,8 @@ type PendingRequest = {
|
||||
readonly expectedCount: number
|
||||
readonly resolve: (vectors: number[][]) => void
|
||||
readonly reject: (error: Error) => void
|
||||
readonly rejectCaller: (error: Error) => void
|
||||
callerError?: Error
|
||||
readonly timeout: ReturnType<typeof setTimeout>
|
||||
removeAbortListener?: () => void
|
||||
}
|
||||
@@ -62,9 +66,10 @@ export class EmbeddingInferenceBrokerError extends Error {
|
||||
| 'START_FAILURE'
|
||||
| 'ENGINE_FAILURE'
|
||||
| 'INVALID_RESULT'
|
||||
| 'OVERLOADED'
|
||||
| 'OVERLOADED',
|
||||
detail?: string
|
||||
) {
|
||||
super(`Embedding inference failed (${code})`)
|
||||
super(`Embedding inference failed (${code})${detail ? `: ${detail}` : ''}`)
|
||||
this.name = 'EmbeddingInferenceBrokerError'
|
||||
}
|
||||
}
|
||||
@@ -100,13 +105,65 @@ export class EmbeddingInferenceBroker {
|
||||
private readonly timeoutMs: number
|
||||
private readonly maxRestarts: number
|
||||
private readonly pending = new Map<string, PendingRequest>()
|
||||
private readonly retiredRequestIds = new Set<string>()
|
||||
private active?: ActiveTransport
|
||||
private starting?: Promise<ActiveTransport>
|
||||
private restarts = 0
|
||||
private everStarted = false
|
||||
private closed = false
|
||||
private shutdownComplete?: () => void
|
||||
private paused = false
|
||||
private stopping = false
|
||||
private managementError?: string
|
||||
|
||||
getManagementState(): 'stopped' | 'stopping' | 'starting' | 'running' | 'idle' | 'error' {
|
||||
if (this.stopping) return 'stopping'
|
||||
if (this.paused || this.closed) return 'stopped'
|
||||
if (this.starting) return 'starting'
|
||||
if (this.active) return 'running'
|
||||
return this.managementError ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
getManagementError(): string | undefined { return this.managementError }
|
||||
|
||||
getResources(): InferenceResources {
|
||||
return this.active?.transport.getResources?.() ?? {
|
||||
scope: 'unavailable', reason: this.active ? '当前执行端未提供进程指标' : '服务进程未运行'
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.closed) throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
this.paused = false
|
||||
this.restarts = 0
|
||||
try {
|
||||
await this.ensureTransport()
|
||||
this.managementError = undefined
|
||||
} catch (error) {
|
||||
this.managementError = String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.paused = true
|
||||
this.stopping = true
|
||||
const error = new Error('向量执行服务已由用户停止;任务不会自动重放')
|
||||
this.rejectCallers(error)
|
||||
try {
|
||||
if (this.starting) await this.starting.catch(() => undefined)
|
||||
const active = this.active
|
||||
if (!active) return
|
||||
await active.transport.close()
|
||||
this.disposeTransport(active.transport, false)
|
||||
this.rejectAll(error)
|
||||
} catch (error) {
|
||||
this.paused = false
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
constructor(options: EmbeddingInferenceBrokerOptions) {
|
||||
if (typeof options.createTransport !== 'function') {
|
||||
@@ -131,12 +188,29 @@ export class EmbeddingInferenceBroker {
|
||||
texts: readonly string[],
|
||||
role: EmbeddingInferenceRole,
|
||||
signal?: AbortSignal
|
||||
): Promise<number[][]> {
|
||||
const controller = new AbortController()
|
||||
return new Promise<number[][]>((resolve, reject) => {
|
||||
// Track worker execution separately from prompt caller cancellation/timeout.
|
||||
void localInferenceService.run('embedding', role === 'query' ? '向量请求 · 查询' : '向量请求 · 文档',
|
||||
(markCancelling) => this.embedRequest(texts, role, signal, controller.signal, (error) => {
|
||||
markCancelling()
|
||||
reject(error)
|
||||
}),
|
||||
{ cancel: () => controller.abort() }).then(resolve, reject)
|
||||
})
|
||||
}
|
||||
|
||||
private async embedRequest(
|
||||
texts: readonly string[], role: EmbeddingInferenceRole, signal: AbortSignal | undefined,
|
||||
managementSignal: AbortSignal,
|
||||
rejectCaller: (error: Error) => void
|
||||
): Promise<number[][]> {
|
||||
const normalizedTexts = validateEmbeddingInferenceTexts(texts)
|
||||
if (role !== 'query' && role !== 'document') {
|
||||
throw new TypeError('role must be query or document')
|
||||
}
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -147,7 +221,7 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
|
||||
const active = await this.ensureTransport()
|
||||
if (this.closed) {
|
||||
if (this.closed || this.paused) {
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
@@ -166,6 +240,7 @@ export class EmbeddingInferenceBroker {
|
||||
expectedCount: normalizedTexts.length,
|
||||
resolve,
|
||||
reject,
|
||||
rejectCaller,
|
||||
timeout
|
||||
}
|
||||
if (signal) {
|
||||
@@ -178,6 +253,21 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
this.pending.set(requestId, pending)
|
||||
|
||||
const requestCancellation = (): void => {
|
||||
// Management cancellation waits for the worker's result or CANCELLED response.
|
||||
try {
|
||||
active.transport.postMessage({ ...embeddingInferenceProtocolBase(), type: 'cancel', requestId })
|
||||
} catch {
|
||||
this.failTransport(active.transport, new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}
|
||||
}
|
||||
const removeSourceAbort = pending.removeAbortListener
|
||||
pending.removeAbortListener = () => {
|
||||
removeSourceAbort?.()
|
||||
managementSignal.removeEventListener('abort', requestCancellation)
|
||||
}
|
||||
managementSignal.addEventListener('abort', requestCancellation, { once: true })
|
||||
|
||||
try {
|
||||
active.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -186,6 +276,7 @@ export class EmbeddingInferenceBroker {
|
||||
role,
|
||||
texts: normalizedTexts
|
||||
})
|
||||
if (managementSignal.aborted) requestCancellation()
|
||||
} catch {
|
||||
this.failTransport(
|
||||
active.transport,
|
||||
@@ -200,10 +291,7 @@ export class EmbeddingInferenceBroker {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
for (const requestId of this.pending.keys()) {
|
||||
this.retireRequestId(requestId)
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.rejectCallers(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
|
||||
let active = this.active
|
||||
if (!active && this.starting) {
|
||||
@@ -261,7 +349,7 @@ export class EmbeddingInferenceBroker {
|
||||
.then(() => this.createTransport())
|
||||
.then((transport) => {
|
||||
if (this.closed) {
|
||||
transport.close()
|
||||
void Promise.resolve(transport.close()).catch(() => undefined)
|
||||
throw new EmbeddingInferenceBrokerError('CLOSED')
|
||||
}
|
||||
const active: ActiveTransport = {
|
||||
@@ -272,7 +360,8 @@ export class EmbeddingInferenceBroker {
|
||||
removeCloseListener: transport.onClose(() =>
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED')
|
||||
new EmbeddingInferenceBrokerError('PROCESS_CLOSED'),
|
||||
true
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -280,6 +369,7 @@ export class EmbeddingInferenceBroker {
|
||||
return active
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof EmbeddingInferenceBrokerError) {
|
||||
throw error
|
||||
}
|
||||
@@ -317,6 +407,7 @@ export class EmbeddingInferenceBroker {
|
||||
)
|
||||
return
|
||||
}
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('CLOSED'))
|
||||
this.shutdownComplete()
|
||||
return
|
||||
}
|
||||
@@ -330,9 +421,6 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
const pending = this.pending.get(message.requestId)
|
||||
if (!pending) {
|
||||
if (this.retiredRequestIds.delete(message.requestId)) {
|
||||
return
|
||||
}
|
||||
this.failTransport(
|
||||
transport,
|
||||
new EmbeddingInferenceBrokerError('PROTOCOL_VIOLATION')
|
||||
@@ -345,7 +433,7 @@ export class EmbeddingInferenceBroker {
|
||||
message.requestId,
|
||||
message.code === 'CANCELLED'
|
||||
? abortError()
|
||||
: new EmbeddingInferenceBrokerError(message.code)
|
||||
: new EmbeddingInferenceBrokerError(message.code, message.message)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -365,11 +453,12 @@ export class EmbeddingInferenceBroker {
|
||||
|
||||
private cancelPending(requestId: string, error: Error): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (!pending) {
|
||||
if (!pending || pending.callerError) {
|
||||
return
|
||||
}
|
||||
this.retireRequestId(requestId)
|
||||
this.finishPending(requestId, error)
|
||||
pending.callerError = error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(error)
|
||||
try {
|
||||
this.active?.transport.postMessage({
|
||||
...embeddingInferenceProtocolBase(),
|
||||
@@ -398,6 +487,7 @@ export class EmbeddingInferenceBroker {
|
||||
this.pending.delete(requestId)
|
||||
clearTimeout(pending.timeout)
|
||||
pending.removeAbortListener?.()
|
||||
error = pending.callerError ?? error
|
||||
if (error) {
|
||||
pending.reject(error)
|
||||
} else {
|
||||
@@ -405,26 +495,23 @@ export class EmbeddingInferenceBroker {
|
||||
}
|
||||
}
|
||||
|
||||
private retireRequestId(requestId: string): void {
|
||||
this.retiredRequestIds.add(requestId)
|
||||
if (this.retiredRequestIds.size > MAX_RETIRED_REQUESTS) {
|
||||
const oldest = this.retiredRequestIds.values().next().value
|
||||
if (typeof oldest === 'string') {
|
||||
this.retiredRequestIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private failTransport(
|
||||
transport: EmbeddingInferenceTransport,
|
||||
error: Error
|
||||
error: Error,
|
||||
closed = false
|
||||
): void {
|
||||
if (this.active?.transport !== transport) {
|
||||
return
|
||||
}
|
||||
this.rejectCallers(error)
|
||||
if (closed) {
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
return
|
||||
}
|
||||
this.managementError = error.message
|
||||
this.disposeTransport(transport, true)
|
||||
this.rejectAll(error)
|
||||
this.shutdownComplete?.()
|
||||
}
|
||||
|
||||
private disposeTransport(
|
||||
@@ -435,15 +522,30 @@ export class EmbeddingInferenceBroker {
|
||||
if (!active || active.transport !== transport) {
|
||||
return
|
||||
}
|
||||
if (close) {
|
||||
try {
|
||||
void Promise.resolve(transport.close()).then(() => {
|
||||
if (this.active !== active) return
|
||||
this.disposeTransport(transport, false)
|
||||
this.rejectAll(new EmbeddingInferenceBrokerError('PROCESS_CLOSED'))
|
||||
}).catch((error: unknown) => {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} catch (error) {
|
||||
this.managementError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.active = undefined
|
||||
active.removeMessageListener()
|
||||
active.removeCloseListener()
|
||||
if (close) {
|
||||
try {
|
||||
transport.close()
|
||||
} catch {
|
||||
// The process is already unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private rejectCallers(error: Error): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.callerError ??= error
|
||||
clearTimeout(pending.timeout)
|
||||
pending.rejectCaller(pending.callerError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -206,13 +206,13 @@ export class EmbeddingInferenceWorker {
|
||||
requestId: message.requestId,
|
||||
vectors
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.postError(
|
||||
message.requestId,
|
||||
controller.signal.aborted ? 'CANCELLED' : 'ENGINE_FAILURE',
|
||||
controller.signal.aborted
|
||||
? 'Embedding inference was cancelled'
|
||||
: 'Embedding engine failed'
|
||||
: error instanceof Error ? error.message : 'Embedding engine failed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app, utilityProcess } from 'electron'
|
||||
import { createInferenceProcessSampler } from '../inference-process-resources'
|
||||
import type { EmbeddingInferenceTransport } from './embedding-inference-broker'
|
||||
|
||||
export function createEmbeddingUtilityTransport(
|
||||
modulePath: string,
|
||||
modelDirectory: string
|
||||
): Promise<EmbeddingInferenceTransport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = utilityProcess.fork(modulePath, [modelDirectory], {
|
||||
serviceName: 'GoodBuddy Embedding Inference',
|
||||
stdio: 'pipe',
|
||||
allowLoadingUnsignedLibraries: false
|
||||
})
|
||||
const startupError = (): void => { reject(new Error('向量进程启动失败')) }
|
||||
child.once('error', startupError)
|
||||
child.once('exit', startupError)
|
||||
child.once('spawn', () => {
|
||||
child.removeListener('error', startupError)
|
||||
child.removeListener('exit', startupError)
|
||||
resolve({
|
||||
getResources: createInferenceProcessSampler(() => child.pid, () => app.getAppMetrics()),
|
||||
postMessage: (message) => child.postMessage(message),
|
||||
onMessage: (listener) => {
|
||||
child.on('message', listener)
|
||||
return () => { child.removeListener('message', listener) }
|
||||
},
|
||||
onClose: (listener) => {
|
||||
let closed = false
|
||||
const notify = (): void => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
listener()
|
||||
}
|
||||
child.on('exit', notify)
|
||||
child.on('error', notify)
|
||||
return () => {
|
||||
child.removeListener('exit', notify)
|
||||
child.removeListener('error', notify)
|
||||
}
|
||||
},
|
||||
close: () => new Promise<void>((resolveClose, rejectClose) => {
|
||||
const finish = (): void => {
|
||||
clearTimeout(timeout)
|
||||
resolveClose()
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('向量进程尚未确认退出,请刷新状态后重试'))
|
||||
}, 5000)
|
||||
child.once('exit', finish)
|
||||
if (!child.kill()) {
|
||||
clearTimeout(timeout)
|
||||
child.removeListener('exit', finish)
|
||||
rejectClose(new Error('无法停止向量进程,请刷新状态后重试'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { Tokenizer } from '@huggingface/tokenizers'
|
||||
import * as ort from 'onnxruntime-web/wasm'
|
||||
import * as ort from 'onnxruntime-web'
|
||||
import type {
|
||||
EmbeddingInferenceEngine,
|
||||
EmbeddingOnnxRuntime,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LocalInferenceService } from './local-inference-service'
|
||||
import type { InferenceService } from '../shared/local-inference-contracts'
|
||||
|
||||
const service: InferenceService = { id: 'embedding', name: 'Embedding', engine: 'ONNX', ownership: 'managed-process', state: 'running', detail: '', actions: ['stop', 'restart'] }
|
||||
|
||||
describe('local inference management', () => {
|
||||
it('does not infer service readiness from an active ASR request', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
registry.register('asr', { snapshot: () => ({ ...service, id: 'asr', state: 'unknown', actions: [] }) })
|
||||
let finish!: () => void
|
||||
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }))
|
||||
expect((await registry.snapshot()).services.find((entry) => entry.id === 'asr')?.state).toBe('unknown')
|
||||
finish()
|
||||
await work
|
||||
})
|
||||
|
||||
it('requires confirmation of every current task and blocks new work during stop', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
let finish!: () => void
|
||||
const work = registry.run('embedding', 'index', () => new Promise<void>((resolve) => { finish = resolve }))
|
||||
const act = vi.fn(async () => {
|
||||
await expect(registry.run('embedding', 'new query', async () => [])).rejects.toThrow('切换')
|
||||
finish()
|
||||
})
|
||||
registry.register('embedding', { snapshot: () => service, act })
|
||||
await expect(registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [] })).rejects.toThrow('重新确认')
|
||||
expect(act).not.toHaveBeenCalled()
|
||||
const task = (await registry.snapshot()).tasks[0]!
|
||||
await registry.act({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: [task.id] })
|
||||
await work
|
||||
expect(act).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for cancellation acknowledgement and preserves natural completion', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
let finish!: () => void
|
||||
const cancel = vi.fn()
|
||||
const work = registry.run('asr', 'speech', () => new Promise<void>((resolve) => { finish = resolve }), { cancel })
|
||||
const id = (await registry.snapshot()).tasks[0]!.id
|
||||
registry.cancel(id)
|
||||
registry.cancel(id)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect((await registry.snapshot()).tasks[0]!.state).toBe('cancelling')
|
||||
finish()
|
||||
await work
|
||||
expect((await registry.snapshot()).tasks[0]!.state).toBe('completed')
|
||||
})
|
||||
|
||||
it('records real failures, bounds history, and isolates unavailable engines', async () => {
|
||||
const registry = new LocalInferenceService()
|
||||
registry.register('asr', { snapshot: () => { throw new Error('missing model') } })
|
||||
for (let index = 0; index < 103; index++) await registry.run('embedding', 'query', async () => [])
|
||||
await expect(registry.run('ocr', 'document', async () => { throw new Error('OCR failed') })).rejects.toThrow('OCR failed')
|
||||
const snapshot = await registry.snapshot()
|
||||
expect(snapshot.tasks).toHaveLength(100)
|
||||
expect(snapshot.tasks[0]).toMatchObject({ state: 'failed', error: 'OCR failed' })
|
||||
expect(snapshot.services.find((entry) => entry.id === 'asr')).toMatchObject({ state: 'error', error: 'Error: missing model' })
|
||||
expect(snapshot.services.find((entry) => entry.id === 'tts')).toMatchObject({ state: 'unavailable', actions: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
inferenceActionSchema,
|
||||
type InferenceAction,
|
||||
type InferenceService,
|
||||
type InferenceServiceId,
|
||||
type InferenceTask,
|
||||
type LocalInferenceSnapshot
|
||||
} from '../shared/local-inference-contracts'
|
||||
|
||||
type Adapter = {
|
||||
snapshot: () => InferenceService | Promise<InferenceService>
|
||||
act?: (action: InferenceAction['action']) => Promise<void>
|
||||
}
|
||||
|
||||
const inventory: InferenceService[] = [
|
||||
{ id: 'asr', name: '语音识别', engine: 'sherpa-onnx', ownership: 'request-worker', state: 'unavailable', detail: '语音服务尚未初始化。', actions: [] },
|
||||
{ id: 'ocr', name: '文字识别', engine: 'PaddleOCR / ONNX Web', ownership: 'renderer-worker', state: 'unknown', detail: '模型由文档解析按需加载,空闲 60 秒自动释放。', actions: [] },
|
||||
{ id: 'embedding', name: '向量生成', engine: 'Granite / ONNX', ownership: 'managed-process', state: 'unavailable', detail: '内置向量提供方尚未初始化;请在设置中安装内置模型并启用内置向量连接。外部向量端点由外部管理,本页不控制其进程。', actions: [] },
|
||||
{ id: 'tts', name: '语音合成', engine: '未接入', ownership: 'unavailable', state: 'unavailable', detail: '当前版本没有语音合成执行服务。', actions: [] }
|
||||
]
|
||||
|
||||
export class LocalInferenceService {
|
||||
private readonly adapters = new Map<InferenceServiceId, Adapter>()
|
||||
private readonly tasks = new Map<string, InferenceTask>()
|
||||
private readonly cancellations = new Map<string, () => void>()
|
||||
private readonly operations = new Set<InferenceServiceId>()
|
||||
|
||||
register(id: InferenceServiceId, adapter: Adapter): () => void {
|
||||
this.adapters.set(id, adapter)
|
||||
return () => { if (this.adapters.get(id) === adapter) this.adapters.delete(id) }
|
||||
}
|
||||
|
||||
async snapshot(): Promise<LocalInferenceSnapshot> {
|
||||
const services = await Promise.all(inventory.map(async (fallback) => {
|
||||
try {
|
||||
return await this.adapters.get(fallback.id)?.snapshot() ?? {
|
||||
...fallback, resources: { scope: 'unavailable' as const, reason: fallback.id === 'tts' ? '尚未接入执行服务' : '服务尚未初始化' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { ...fallback, state: 'error' as const, error: String(error), actions: [] }
|
||||
}
|
||||
}))
|
||||
return { services, tasks: [...this.tasks.values()].map((task) => ({ ...task })).reverse() }
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
serviceId: InferenceServiceId,
|
||||
source: string,
|
||||
work: (markCancelling: () => void) => Promise<T>,
|
||||
options: { cancel?: () => void; model?: string; cancelUnavailableReason?: string } = {}
|
||||
): Promise<T> {
|
||||
if (this.operations.has(serviceId)) throw new Error('服务正在切换状态,请稍后重试')
|
||||
const id = randomUUID()
|
||||
const task: InferenceTask = {
|
||||
id, serviceId, source, model: options.model, state: 'running', startedAt: Date.now(),
|
||||
cancelUnavailableReason: options.cancel ? undefined : options.cancelUnavailableReason ?? '该引擎未提供独立任务取消接口。'
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
if (options.cancel) this.cancellations.set(id, options.cancel)
|
||||
try {
|
||||
const result = await work(() => {
|
||||
if (task.finishedAt === undefined) task.state = 'cancelling'
|
||||
})
|
||||
task.state = 'completed'
|
||||
return result
|
||||
} catch (error) {
|
||||
task.state = error instanceof Error && error.name === 'AbortError' ? 'cancelled' : 'failed'
|
||||
task.error = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
} finally {
|
||||
task.finishedAt = Date.now()
|
||||
this.cancellations.delete(id)
|
||||
const finished = [...this.tasks.values()].filter((entry) => entry.finishedAt !== undefined)
|
||||
for (const entry of finished.slice(0, Math.max(0, finished.length - 100))) this.tasks.delete(entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
cancel(taskId: string): void {
|
||||
const task = this.tasks.get(taskId)
|
||||
if (!task || task.finishedAt !== undefined) return
|
||||
if (task.state === 'cancelling') return
|
||||
const cancel = this.cancellations.get(taskId)
|
||||
if (!cancel) throw new Error(task.cancelUnavailableReason)
|
||||
cancel()
|
||||
task.state = 'cancelling'
|
||||
}
|
||||
|
||||
async act(input: InferenceAction): Promise<void> {
|
||||
const request = inferenceActionSchema.parse(input)
|
||||
const adapter = this.adapters.get(request.serviceId)
|
||||
if (!adapter?.act) throw new Error('该服务不支持手动进程控制')
|
||||
if (this.operations.has(request.serviceId)) throw new Error('服务操作正在进行')
|
||||
this.operations.add(request.serviceId)
|
||||
try {
|
||||
const state = await adapter.snapshot()
|
||||
if (!state.actions.includes(request.action)) throw new Error('当前状态不支持此操作')
|
||||
const active = [...this.tasks.values()].filter((task) => task.serviceId === request.serviceId && task.finishedAt === undefined)
|
||||
if (request.action !== 'start' && active.some((task) => !request.confirmedTaskIds.includes(task.id))) {
|
||||
throw new Error('使用此服务的任务已变化,请刷新并重新确认影响')
|
||||
}
|
||||
await adapter.act(request.action)
|
||||
} finally {
|
||||
this.operations.delete(request.serviceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const localInferenceService = new LocalInferenceService()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { localInferenceService } from '../local-inference-service'
|
||||
import {
|
||||
speechTranscriptionInputSchema,
|
||||
speechTranscriptionResultSchema,
|
||||
@@ -190,9 +191,10 @@ export const runSherpaTranscription: SpeechTranscriptionRunner = (
|
||||
signal.removeEventListener('abort', abort)
|
||||
worker.removeAllListeners()
|
||||
if (terminate) {
|
||||
void worker.terminate()
|
||||
void worker.terminate().then(action, reject)
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
action()
|
||||
}
|
||||
const abort = (): void =>
|
||||
finish(() => reject(createAbortError()))
|
||||
@@ -265,21 +267,23 @@ export class SpeechTranscriptionService {
|
||||
}
|
||||
const controller = new AbortController()
|
||||
this.active.set(request.requestId, controller)
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
return localInferenceService.run('asr', '语音输入', async () => {
|
||||
try {
|
||||
const model = await this.models.getSelectedRuntimeModel()
|
||||
if (!model) {
|
||||
throw new Error('请先在设置中安装并选择本地语音模型')
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
const text = await this.runner(
|
||||
createSherpaRecognizerConfig(model),
|
||||
samples,
|
||||
request.sampleRate,
|
||||
controller.signal
|
||||
)
|
||||
return speechTranscriptionResultSchema.parse({ text })
|
||||
} finally {
|
||||
this.active.delete(request.requestId)
|
||||
}
|
||||
}, { cancel: () => controller.abort() })
|
||||
}
|
||||
|
||||
cancel(requestId: SpeechTranscriptionInput['requestId']): boolean {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { DesktopApi } from '../shared/contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
contextBridge: { exposeInMainWorld: vi.fn() },
|
||||
ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() },
|
||||
webUtils: {}
|
||||
}))
|
||||
vi.mock('electron', () => electron)
|
||||
|
||||
it('reads execution statistics through the explicit typed bridge', async () => {
|
||||
await import('./index')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
||||
const summary = { durationMs: 1000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 0, asOf: 2000, taskDurations: [{ id: 'task', durationMs: 1000, incompleteRequestCount: 0 }] }
|
||||
electron.ipcRenderer.invoke.mockResolvedValue(summary)
|
||||
for (const scope of [{ conversationId: 'conversation' }, { projectId: 'project' }]) {
|
||||
expect(await api.tasks.getExecutionStats(scope)).toBe(summary)
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.tasksExecutionStats, scope)
|
||||
}
|
||||
})
|
||||
@@ -65,6 +65,7 @@ import type {
|
||||
AssistantHeartbeatRun,
|
||||
AssistantExpert,
|
||||
AssistantTask,
|
||||
ExecutionStats,
|
||||
ActivityHistorySnapshot,
|
||||
ActivityRecord,
|
||||
TokenUsageSummary,
|
||||
@@ -674,6 +675,14 @@ const desktopApi: DesktopApi = {
|
||||
}
|
||||
},
|
||||
updates: {
|
||||
onSettingsChanged: (listener) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
settings: ApplicationSettings
|
||||
): void => listener(settings)
|
||||
ipcRenderer.on(ipcChannels.applicationSettingsChanged, handler)
|
||||
return () => ipcRenderer.removeListener(ipcChannels.applicationSettingsChanged, handler)
|
||||
},
|
||||
getSettings: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.applicationSettingsGet
|
||||
@@ -779,6 +788,12 @@ const desktopApi: DesktopApi = {
|
||||
)
|
||||
}
|
||||
},
|
||||
localInference: {
|
||||
openSettings: () => ipcRenderer.invoke(ipcChannels.localInferenceOpenSettings),
|
||||
getSnapshot: () => ipcRenderer.invoke(ipcChannels.localInferenceGet),
|
||||
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
|
||||
cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId })
|
||||
},
|
||||
speechModels: {
|
||||
getSnapshot: () =>
|
||||
ipcRenderer.invoke(
|
||||
@@ -1216,6 +1231,8 @@ const desktopApi: DesktopApi = {
|
||||
tasks: {
|
||||
list: () =>
|
||||
ipcRenderer.invoke(ipcChannels.tasksList) as Promise<AssistantTask[]>,
|
||||
getExecutionStats: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.tasksExecutionStats, input) as Promise<ExecutionStats>,
|
||||
setStatus: async (
|
||||
taskId: string,
|
||||
status: 'completed' | 'cancelled'
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import './anchored-menu.css'
|
||||
|
||||
export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
|
||||
anchorRef: RefObject<HTMLButtonElement | null>
|
||||
id: string
|
||||
label: string
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const dismiss = useEffectEvent(onClose)
|
||||
const position = useEffectEvent(() => {
|
||||
const menu = menuRef.current
|
||||
const anchor = anchorRef.current
|
||||
if (!menu || !anchor) return
|
||||
const viewport = window.visualViewport
|
||||
const left = viewport?.offsetLeft ?? 0
|
||||
const top = viewport?.offsetTop ?? 0
|
||||
const width = viewport?.width ?? window.innerWidth
|
||||
const height = viewport?.height ?? window.innerHeight
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const menuWidth = Math.max(0, Math.min(280, width - 32))
|
||||
const bottom = Math.max(top + 16, Math.min(rect.top - 8, top + height - 16))
|
||||
menu.style.width = `${menuWidth}px`
|
||||
menu.style.maxHeight = `${Math.max(0, bottom - top - 16)}px`
|
||||
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
|
||||
menu.style.top = `${Math.max(top + 16, bottom - menu.getBoundingClientRect().height)}px`
|
||||
})
|
||||
useLayoutEffect(() => { position() })
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current!
|
||||
const anchor = anchorRef.current
|
||||
const focusFirst = (): void => {
|
||||
if (document.activeElement === document.body || document.activeElement === menu) {
|
||||
(menu.querySelector<HTMLElement>('[role="menuitem"]:not(:disabled)') ?? menu).focus()
|
||||
}
|
||||
}
|
||||
menu.focus()
|
||||
focusFirst()
|
||||
const outside = (event: Event): void => {
|
||||
if (!menu.contains(event.target as Node) && !anchor?.contains(event.target as Node)) dismiss()
|
||||
}
|
||||
const keydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
flushSync(() => dismiss())
|
||||
anchor?.focus()
|
||||
return
|
||||
}
|
||||
if (!menu.contains(event.target as Node)) return
|
||||
if (event.key === 'Tab') {
|
||||
flushSync(() => dismiss())
|
||||
anchor?.focus()
|
||||
return
|
||||
}
|
||||
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
|
||||
const index = items.indexOf(document.activeElement as HTMLElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||
: (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||
items[next]?.focus()
|
||||
}
|
||||
const reposition = (): void => { position(); focusFirst() }
|
||||
const observer = new ResizeObserver(reposition)
|
||||
observer.observe(menu)
|
||||
if (anchor) observer.observe(anchor)
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('focusin', outside)
|
||||
document.addEventListener('keydown', keydown, true)
|
||||
window.addEventListener('resize', reposition)
|
||||
window.addEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.addEventListener('resize', reposition)
|
||||
window.visualViewport?.addEventListener('scroll', reposition)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
document.removeEventListener('pointerdown', outside)
|
||||
document.removeEventListener('focusin', outside)
|
||||
document.removeEventListener('keydown', keydown, true)
|
||||
window.removeEventListener('resize', reposition)
|
||||
window.removeEventListener('scroll', reposition, true)
|
||||
window.visualViewport?.removeEventListener('resize', reposition)
|
||||
window.visualViewport?.removeEventListener('scroll', reposition)
|
||||
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
|
||||
}
|
||||
}, [anchorRef])
|
||||
return createPortal(<div ref={menuRef} id={id} role="menu" aria-label={label}
|
||||
tabIndex={-1} className="anchored-menu">{children}</div>, document.body)
|
||||
}
|
||||
+374
-45
@@ -19,6 +19,7 @@ import type {
|
||||
} from "../../shared/contracts";
|
||||
import {
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings,
|
||||
} from "../../shared/application-settings-contracts";
|
||||
import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut";
|
||||
@@ -181,6 +182,12 @@ const terminalSnapshot: TerminalSnapshot = {
|
||||
};
|
||||
|
||||
const api: DesktopApi = {
|
||||
localInference: {
|
||||
openSettings: vi.fn(async () => undefined),
|
||||
getSnapshot: vi.fn(async () => ({ services: [], tasks: [] })),
|
||||
act: vi.fn(async () => undefined),
|
||||
cancel: vi.fn(async () => undefined)
|
||||
},
|
||||
storageUpgrade: {
|
||||
getProgress: vi.fn(),
|
||||
act: vi.fn(async () => undefined)
|
||||
@@ -572,6 +579,10 @@ const api: DesktopApi = {
|
||||
},
|
||||
tasks: {
|
||||
list: vi.fn(async () => []),
|
||||
getExecutionStats: vi.fn(async () => ({
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: [],
|
||||
})),
|
||||
setStatus: vi.fn(async () => {}),
|
||||
},
|
||||
activityHistory: {
|
||||
@@ -1048,6 +1059,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: enabled,
|
||||
magicNotesEnabled: false,
|
||||
@@ -1067,6 +1079,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
return { updateSettings };
|
||||
}
|
||||
@@ -1224,7 +1237,7 @@ describe("App", () => {
|
||||
vi.mocked(api.activityHistory.replace).mockReset().mockResolvedValue();
|
||||
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([]);
|
||||
api.channels = undefined;
|
||||
api.updates = undefined;
|
||||
installRemoteProjectsSetting(false);
|
||||
newConversationListener = undefined;
|
||||
beforeQuitListener = undefined;
|
||||
browserListener = undefined;
|
||||
@@ -1431,7 +1444,7 @@ describe("App", () => {
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
const summary = await screen.findByRole("button", { name: /全项目活动/u });
|
||||
expect(summary).toHaveTextContent("1 个运行中");
|
||||
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
|
||||
const settings = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
expect(selected).toHaveClass("conversation-item--active");
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{ ...currentTask, status: "completed" }]);
|
||||
@@ -1514,7 +1527,7 @@ describe("App", () => {
|
||||
it("respects the Settings leave guard before changing project and conversation", async () => {
|
||||
render(<App />);
|
||||
await screen.findByRole("button", { name: /全项目活动/u });
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), { target: { value: "C:\\Unsaved activity draft" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
|
||||
@@ -1749,6 +1762,105 @@ describe("App", () => {
|
||||
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("custom task creation", () => {
|
||||
const currentId = "00000000-0000-4000-8000-000000000811";
|
||||
const otherId = "00000000-0000-4000-8000-000000000812";
|
||||
const snapshots: ConversationSnapshot[] = [
|
||||
{ id: currentId, projectId, title: "Task current", updatedAt: 300, messages: [] },
|
||||
{ id: otherId, projectId, title: "Task other", updatedAt: 200, messages: [] },
|
||||
{ id: "00000000-0000-4000-8000-000000000813", projectId, title: "Task remote", updatedAt: 100, messages: [],
|
||||
remote: { channel: "weixin", accountDisplay: "Remote account", conversationType: "direct" } },
|
||||
{ id: "00000000-0000-4000-8000-000000000814", projectId: "other-project", title: "Task elsewhere", updatedAt: 50, messages: [] },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
|
||||
vi.mocked(api.schedules.create).mockReset().mockImplementation(async (input) => ({
|
||||
...input,
|
||||
workMode: "execute",
|
||||
id: "00000000-0000-4000-8000-000000000815",
|
||||
taskId: "00000000-0000-4000-8000-000000000816",
|
||||
conversationId: input.conversationId ?? "00000000-0000-4000-8000-000000000817",
|
||||
enabled: !input.runImmediately,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
});
|
||||
|
||||
async function openDialog(): Promise<HTMLElement> {
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "切换助手工作栏" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "任务中心" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "新建任务" }));
|
||||
return screen.getByRole("dialog", { name: "新建定制任务" });
|
||||
}
|
||||
|
||||
it("creates immediately in the current conversation with one IPC call and no follow-up runNow", async () => {
|
||||
const dialog = await openDialog();
|
||||
expect(within(dialog).getByLabelText("关联会话")).toHaveValue("current");
|
||||
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "立即检查项目" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
|
||||
expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
conversationId: currentId, projectId, title: "立即检查项目", prompt: "立即检查项目",
|
||||
recurrence: "once", runImmediately: true,
|
||||
}));
|
||||
expect(api.schedules.runNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([otherId, "new"])("creates in the selected destination %s while excluding remote and other-project conversations", async (destination) => {
|
||||
const dialog = await openDialog();
|
||||
expect(within(dialog).getByRole("option", { name: "Task other" })).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("option", { name: "Task remote" })).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("option", { name: "Task elsewhere" })).not.toBeInTheDocument();
|
||||
fireEvent.change(within(dialog).getByLabelText("关联会话"), { target: { value: destination } });
|
||||
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "检查所选会话" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledOnce());
|
||||
const input = vi.mocked(api.schedules.create).mock.calls[0]![0];
|
||||
if (destination === "new") expect(input).not.toHaveProperty("conversationId");
|
||||
else expect(input.conversationId).toBe(otherId);
|
||||
expect(input.runImmediately).toBe(true);
|
||||
expect(api.schedules.runNow).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("validates future scheduled time before creating and retains recurrence", async () => {
|
||||
const dialog = await openDialog();
|
||||
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "每周检查" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "定时执行" }));
|
||||
fireEvent.change(within(dialog).getByLabelText("运行频率"), { target: { value: "weekly" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2020-01-01T12:00" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("首次运行时间必须晚于当前时间");
|
||||
expect(api.schedules.create).not.toHaveBeenCalled();
|
||||
fireEvent.change(within(dialog).getByLabelText("首次运行"), { target: { value: "2099-01-01T12:00" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
await waitFor(() => expect(api.schedules.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
|
||||
recurrence: "weekly", runImmediately: false, nextRunAt: new Date("2099-01-01T12:00").toISOString(),
|
||||
})));
|
||||
expect(api.schedules.runNow).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("retains the draft after create failure and does not retry creation after a refresh failure", async () => {
|
||||
const dialog = await openDialog();
|
||||
vi.mocked(api.schedules.create).mockRejectedValueOnce(new Error("队列写入失败"));
|
||||
fireEvent.change(within(dialog).getByLabelText("任务内容"), { target: { value: "保留任务内容" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("队列写入失败");
|
||||
expect(within(dialog).getByLabelText("任务内容")).toHaveValue("保留任务内容");
|
||||
expect(api.schedules.create).toHaveBeenCalledOnce();
|
||||
vi.mocked(api.conversations.listSummaries).mockRejectedValueOnce(new Error("刷新失败"));
|
||||
vi.mocked(api.tasks.list).mockRejectedValueOnce(new Error("刷新失败"));
|
||||
vi.mocked(api.schedules.list).mockRejectedValueOnce(new Error("刷新失败"));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "创建任务" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "新建定制任务" })).not.toBeInTheDocument());
|
||||
expect(api.schedules.create).toHaveBeenCalledTimes(2);
|
||||
expect(api.schedules.runNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("provides custom minimize, maximize, and close controls", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
@@ -1803,7 +1915,7 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Current project" }),
|
||||
).toHaveTextContent("Default project");
|
||||
expect(screen.getByText("Project: Default project")).toHaveClass(
|
||||
expect(screen.getByLabelText("Project: Default project")).toHaveClass(
|
||||
"scope-badge",
|
||||
);
|
||||
expect(
|
||||
@@ -1877,7 +1989,7 @@ describe("App", () => {
|
||||
within(assistantSidebar).getByText("Project: Default project"),
|
||||
).toBeInTheDocument();
|
||||
const taskIndexHeading = within(assistantSidebar).getByRole("heading", {
|
||||
name: "Task index",
|
||||
name: /Project tasks/,
|
||||
});
|
||||
const newTaskButton = within(assistantSidebar).getByRole("button", {
|
||||
name: "New task",
|
||||
@@ -1891,10 +2003,8 @@ describe("App", () => {
|
||||
within(taskDialog).getByText("Default project"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(taskDialog).getByRole("button", {
|
||||
name: "Current conversation",
|
||||
}),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
within(taskDialog).getByLabelText("Conversation"),
|
||||
).toHaveValue("current");
|
||||
fireEvent.click(
|
||||
within(taskDialog).getByRole("button", {
|
||||
name: "Close new custom task",
|
||||
@@ -2582,7 +2692,7 @@ describe("App", () => {
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /本地工作区/u,
|
||||
name: /^设置$/u,
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
|
||||
@@ -2713,7 +2823,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
|
||||
await screen.findByRole("heading", { name: "运行记录" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
|
||||
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
|
||||
@@ -2867,6 +2977,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -2878,6 +2989,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(
|
||||
@@ -2888,7 +3000,7 @@ describe("App", () => {
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /本地工作区/u,
|
||||
name: /^设置$/u,
|
||||
}),
|
||||
);
|
||||
await screen.findByRole(
|
||||
@@ -2966,6 +3078,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -2978,6 +3091,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -2988,6 +3102,7 @@ describe("App", () => {
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
@@ -3075,6 +3190,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -3087,6 +3203,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -3097,6 +3214,7 @@ describe("App", () => {
|
||||
check,
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
const first = render(<App />);
|
||||
@@ -3545,7 +3663,13 @@ describe("App", () => {
|
||||
target: { value: "方案讨论更新" },
|
||||
});
|
||||
fireEvent.submit(renameInput.closest("form")!);
|
||||
expect(await screen.findAllByText("方案讨论更新")).toHaveLength(2);
|
||||
expect(
|
||||
await within(container.querySelector(".conversation-list") as HTMLElement)
|
||||
.findByText("方案讨论更新"),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector(".conversation-title")).toHaveTextContent(
|
||||
/^方案讨论更新$/,
|
||||
);
|
||||
vi.mocked(api.conversations.saveLocal).mockClear();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新"));
|
||||
@@ -4442,7 +4566,7 @@ describe("App", () => {
|
||||
for (const reasoning of completedReasoning) {
|
||||
expect(reasoning.closest("details")).not.toHaveAttribute("open");
|
||||
}
|
||||
expect(screen.getByText("项目:默认项目")).toHaveClass("scope-badge");
|
||||
expect(screen.getByLabelText("项目:默认项目")).toHaveClass("scope-badge");
|
||||
});
|
||||
|
||||
it("keeps every activity detail in a long tool chain", async () => {
|
||||
@@ -5088,7 +5212,7 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /本地工作区/u }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^设置$/u }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "上下文控制" }));
|
||||
const trigger = await screen.findByLabelText("压缩触发阈值");
|
||||
@@ -5113,7 +5237,7 @@ describe("App", () => {
|
||||
it("guards sidebar navigation away from dirty Settings drafts", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.change(await screen.findByLabelText("默认工作区目录"), {
|
||||
target: { value: "C:\\Unsaved from App" },
|
||||
@@ -5137,7 +5261,7 @@ describe("App", () => {
|
||||
|
||||
await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
const settingsTrigger = screen.getByRole("button", {
|
||||
name: /本地工作区/u,
|
||||
name: /^设置$/u,
|
||||
});
|
||||
fireEvent.click(settingsTrigger);
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
@@ -5158,7 +5282,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
const workspace = document.querySelector(`[data-route="${route}"]`)!;
|
||||
workspace.scrollTop = 120;
|
||||
const trigger = screen.getByRole("button", { name: /本地工作区/u });
|
||||
const trigger = screen.getByRole("button", { name: /^设置$/u });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole("dialog", { name: "设置中心" });
|
||||
@@ -5204,6 +5328,7 @@ describe("App", () => {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -5235,6 +5360,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
api.shortcuts = {
|
||||
getSettings: vi.fn(async () => shortcutSnapshot),
|
||||
@@ -5256,7 +5382,7 @@ describe("App", () => {
|
||||
try {
|
||||
render(<App />);
|
||||
expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
const shortcutInput = await screen.findByLabelText("快捷键");
|
||||
@@ -5273,7 +5399,7 @@ describe("App", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||||
expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
const shortcutSwitch = await screen.findByRole("switch", {
|
||||
@@ -5657,7 +5783,7 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("restores an independent knowledge scope for each conversation", async () => {
|
||||
it("restores independent knowledge scopes across conversations and the application center", async () => {
|
||||
const libraryId = "11111111-1111-4111-8111-111111111111";
|
||||
const firstConversationId = "22222222-2222-4222-8222-222222222222";
|
||||
const secondConversationId = "33333333-3333-4333-8333-333333333333";
|
||||
@@ -5725,6 +5851,14 @@ describe("App", () => {
|
||||
name: "选择知识库,本次已启用 1 个",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }));
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '知识库' } });
|
||||
expect(within(screen.getByRole('dialog')).queryByRole('switch')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }));
|
||||
openComposerOptions();
|
||||
expect(screen.getByRole('button', { name: '选择知识库,本次已启用 1 个' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.composer__option-summary')).toHaveTextContent('每次先检索');
|
||||
});
|
||||
|
||||
it("persists and submits always-retrieve mode for the active conversation", async () => {
|
||||
@@ -6555,7 +6689,7 @@ describe("App", () => {
|
||||
it("opens chat and focuses the composer for tray conversations", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "设置中心" }),
|
||||
).toBeInTheDocument();
|
||||
@@ -7008,7 +7142,7 @@ describe("App", () => {
|
||||
it("applies and persists a dark appearance from Settings", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
await screen.findByRole("heading", { name: "设置中心" }, { timeout: 3000 });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "外观" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /暗色/u }));
|
||||
@@ -8194,7 +8328,7 @@ describe("App", () => {
|
||||
).toHaveTextContent(remoteProject.name),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("本地工作区"));
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", {
|
||||
@@ -9444,14 +9578,14 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
expect(await screen.findByText('当前聊天模型无法自动调用图片工具,可使用已有直连图片工作流。')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Keep my image request' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '外观' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
|
||||
expect(await screen.findByRole('tab', { name: '模型连接' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveValue('Keep my image request');
|
||||
fireEvent.click(screen.getByRole('button', { name: /本地工作区/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^设置$/u }));
|
||||
expect(await screen.findByRole('tab', { name: 'Agent Runtime' })).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往图片模型设置' }));
|
||||
@@ -11635,7 +11769,7 @@ describe("App", () => {
|
||||
it("configures a runtime without reading an existing API key", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByText("本地工作区"));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '设置' }));
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
name: "设置中心",
|
||||
@@ -11738,7 +11872,7 @@ describe("App", () => {
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("没有活动项目。请选择其他任务范围或先打开一个项目。"),
|
||||
screen.getByText("没有活动项目。请先打开一个项目以查看和创建任务。"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: "当前会话的任务" }),
|
||||
@@ -12284,7 +12418,7 @@ describe("App", () => {
|
||||
it("opens Smart Heartbeat as a first-class workspace", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "智能心跳" }),
|
||||
@@ -12378,7 +12512,7 @@ describe("App", () => {
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
|
||||
expect(await screen.findByText("知识库加载失败")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("知识数据库暂时不可用")).toHaveLength(1);
|
||||
expect(screen.queryByText("建立第一个知识库")).not.toBeInTheDocument();
|
||||
@@ -12434,7 +12568,7 @@ describe("App", () => {
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole(
|
||||
"button",
|
||||
@@ -12465,7 +12599,7 @@ describe("App", () => {
|
||||
);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
|
||||
expect(await screen.findByText("智能心跳加载失败")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "配置智能心跳" }),
|
||||
@@ -12511,7 +12645,7 @@ describe("App", () => {
|
||||
]);
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "智能心跳" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "智能心跳" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "心跳计划" }));
|
||||
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
|
||||
selectProjectOption(secondProject.name);
|
||||
@@ -12522,7 +12656,7 @@ describe("App", () => {
|
||||
|
||||
it("automatically snapshots the conversation project on new activity", async () => {
|
||||
render(<App />);
|
||||
await screen.findByText("项目:默认项目");
|
||||
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "记录项目范围" },
|
||||
@@ -12564,7 +12698,7 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
await screen.findByText("项目:默认项目");
|
||||
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.activityHistory.replace).toHaveBeenCalledWith(
|
||||
@@ -12588,7 +12722,7 @@ describe("App", () => {
|
||||
name: "主导航",
|
||||
});
|
||||
const chat = within(navigation).getByRole("button", { name: "对话" });
|
||||
const knowledge = within(navigation).getByRole("button", {
|
||||
const knowledge = await within(navigation).findByRole("button", {
|
||||
name: "知识库",
|
||||
});
|
||||
|
||||
@@ -12782,6 +12916,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -12794,6 +12929,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -12804,10 +12940,11 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
await screen.findByText("项目:默认项目");
|
||||
await screen.findByLabelText("项目:默认项目");
|
||||
const magicNotesEntry = await screen.findByRole("button", {
|
||||
name: "魔法笔记",
|
||||
});
|
||||
@@ -12842,6 +12979,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -12853,6 +12991,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
@@ -12897,6 +13036,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -12908,6 +13048,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
@@ -12929,6 +13070,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -12941,6 +13083,7 @@ describe("App", () => {
|
||||
updateSource: "github" as const,
|
||||
modelDownloadSource: "modelscope" as const,
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -12951,6 +13094,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
render(<App />);
|
||||
@@ -12998,6 +13142,7 @@ describe("App", () => {
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -13017,6 +13162,7 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
|
||||
render(<App />);
|
||||
@@ -13025,7 +13171,7 @@ describe("App", () => {
|
||||
await screen.findByTitle("Agent 回复 HTML 静态预览"),
|
||||
).toHaveAttribute("sandbox", "");
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /本地工作区/u }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /^设置$/u }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
name: "在会话中渲染 HTML",
|
||||
@@ -13046,12 +13192,13 @@ describe("App", () => {
|
||||
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps platform-feature switches in Settings without navigating", async () => {
|
||||
it("omits global notes settings while retaining application center settings without navigating the workspace", async () => {
|
||||
let applicationSettings: ApplicationSettings = {
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: "github",
|
||||
modelDownloadSource: "modelscope",
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -13071,25 +13218,34 @@ describe("App", () => {
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(async () => {}),
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {}),
|
||||
};
|
||||
try {
|
||||
const { container } = render(<App />);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: /本地工作区/u,
|
||||
name: /^设置$/u,
|
||||
}),
|
||||
);
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "魔法笔记" }));
|
||||
await screen.findByRole("tab", { name: "通用设置" });
|
||||
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
|
||||
expect(api.updates.updateSettings).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "关闭设置" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用中心" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "管理应用" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "魔法笔记 应用设置" }));
|
||||
const toggle = await screen.findByRole("switch", {
|
||||
name: "显示魔法笔记入口",
|
||||
name: "启用应用",
|
||||
});
|
||||
await waitFor(() => expect(toggle).toBeEnabled());
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(toggle).toBeChecked());
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "设置中心" }),
|
||||
screen.getByRole("heading", { name: "魔法笔记" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "魔法笔记" }),
|
||||
@@ -13101,7 +13257,7 @@ describe("App", () => {
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(toggle).not.toBeChecked());
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "设置中心" }),
|
||||
screen.getByRole("heading", { name: "魔法笔记" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "魔法笔记" }),
|
||||
@@ -13111,10 +13267,183 @@ describe("App", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes externally changed navigation before editing and keeps Knowledge in its fixed slot', async () => {
|
||||
const updates = api.updates!
|
||||
const original = await updates.getSettings()
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '智能心跳' })
|
||||
const external = {
|
||||
...original,
|
||||
applicationNavigation: {
|
||||
...original.applicationNavigation,
|
||||
order: ['local-inference', 'magic-notes'] as const,
|
||||
},
|
||||
}
|
||||
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: '本机推理 应用设置' }))
|
||||
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'],
|
||||
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(['read', 'mutation'] as const)('keeps a settings event authoritative over a stale %s reply', async (operation) => {
|
||||
const updates = api.updates!
|
||||
const original = await updates.getSettings()
|
||||
let changed!: Parameters<typeof updates.onSettingsChanged>[0]
|
||||
vi.mocked(updates.onSettingsChanged).mockImplementation(listener => {
|
||||
changed = listener
|
||||
return vi.fn()
|
||||
})
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '智能心跳' })
|
||||
const reply = deferred<ApplicationSettings>()
|
||||
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: '本机推理 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
if (operation === 'mutation') {
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
vi.mocked(updates.updateSettings).mockReturnValueOnce(reply.promise)
|
||||
fireEvent.click(pin)
|
||||
}
|
||||
const external: ApplicationSettings = {
|
||||
...original,
|
||||
applicationNavigation: {
|
||||
order: ['local-inference', 'magic-notes'],
|
||||
pinned: { ...original.applicationNavigation.pinned, 'local-inference': false },
|
||||
},
|
||||
}
|
||||
await act(async () => changed(external))
|
||||
expect(pin).not.toBeChecked()
|
||||
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: '主导航' })).getByRole('button', { name: '智能心跳' })).toBeInTheDocument()
|
||||
fireEvent.click(pin)
|
||||
expect(updates.updateSettings).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: { ...external.applicationNavigation, pinned: { ...external.applicationNavigation.pinned, 'local-inference': true } },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps fixed apps available when application settings cannot be loaded', async () => {
|
||||
vi.mocked(api.updates!.getSettings).mockRejectedValue(new Error('unavailable'))
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
expect(await screen.findByLabelText('知识工作区')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '智能心跳' }))
|
||||
expect(await screen.findByRole('heading', { name: '智能心跳' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
expect(api.heartbeats.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['sidebar', 'menu', 'center'])('opens local inference from %s as a modal preserving the workspace', async (entry) => {
|
||||
render(<App />)
|
||||
const nav = await screen.findByRole('button', { name: '本机推理' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
|
||||
const workspace = await screen.findByLabelText('知识工作区')
|
||||
if (entry === 'sidebar') {
|
||||
nav.focus()
|
||||
fireEvent.click(nav)
|
||||
} else {
|
||||
fireEvent.click(screen.getByRole('button', { 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')!
|
||||
fireEvent.click(within(item).getByRole('button', { 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: '关闭本机推理' })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.click(close)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('知识工作区')).toBe(workspace)
|
||||
expect(workspace.closest('.app-shell')).toHaveProperty('inert', false)
|
||||
expect(entry === 'sidebar' ? nav : screen.getByRole('button', { name: '应用中心' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('opens an unpinned app and recovers a disabled current page without remounting it', async () => {
|
||||
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: '本机推理' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
fireEvent.click(pin)
|
||||
await waitFor(() => expect(inferenceNav).not.toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
const trigger = screen.getByRole('button', { name: '应用中心' })
|
||||
expect(trigger).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '本机推理' }))
|
||||
const workspace = await screen.findByRole('region', { name: '本机推理服务' })
|
||||
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: '应用设置' }))
|
||||
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)
|
||||
})
|
||||
|
||||
it('keeps confirmed application preferences and locks edits until an unknown save is read back', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('button', { name: '知识库' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用中心' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
const updates = api.updates!
|
||||
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).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
|
||||
await waitFor(() => expect(pin).not.toBeDisabled())
|
||||
expect(updates.updateSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("gives the knowledge workspace the full content width", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "知识库" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "知识库" }));
|
||||
|
||||
expect(await screen.findByLabelText("知识工作区")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
|
||||
|
||||
+271
-113
@@ -1,8 +1,24 @@
|
||||
import { ImageCapabilityNotice } from "./ImageCapabilityNotice";
|
||||
import LocalInferencePage from "./LocalInferencePage";
|
||||
import { ApplicationMenu } from './ApplicationMenu';
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsNavigation,
|
||||
applicationDefinitions,
|
||||
isApplicationEnabled,
|
||||
} from "./ApplicationCenter";
|
||||
import {
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings,
|
||||
type ApplicationSettingsUpdate,
|
||||
type EditableApplicationId,
|
||||
} from "../../shared/application-settings-contracts";
|
||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||
import {
|
||||
ArrowDown,
|
||||
Bot,
|
||||
ChartColumn,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
@@ -14,7 +30,7 @@ import {
|
||||
Edit3,
|
||||
FileText,
|
||||
GitFork,
|
||||
HeartPulse,
|
||||
Grid2X2,
|
||||
Info,
|
||||
Library,
|
||||
LoaderCircle,
|
||||
@@ -172,6 +188,7 @@ import { ProjectSwitcher } from "./ProjectSwitcher";
|
||||
import { ProjectActivity } from "./ProjectActivity";
|
||||
import { deriveConversationActivity } from "./conversation-activity";
|
||||
import { useUnviewedCompletions } from "./use-unviewed-completions";
|
||||
import { useExecutionStats } from "./use-execution-stats";
|
||||
import {
|
||||
RightAssistantSidebar,
|
||||
type AssistantSidebarTab,
|
||||
@@ -180,6 +197,7 @@ import {
|
||||
} from "./RightAssistantSidebar";
|
||||
import {
|
||||
CustomTaskDialog,
|
||||
type CustomTaskCreateOptions,
|
||||
type CustomTaskDestination,
|
||||
} from "./CustomTaskDialog";
|
||||
import { ConversationTaskStrip } from "./ConversationTaskStrip";
|
||||
@@ -508,7 +526,7 @@ type ActiveRun = {
|
||||
};
|
||||
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "activity" | "settings";
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "activity" | "settings";
|
||||
|
||||
const intentRoutePreloaders: Partial<
|
||||
Record<WorkspaceView, () => Promise<unknown>>
|
||||
@@ -2399,6 +2417,17 @@ function App(): React.JSX.Element {
|
||||
>({});
|
||||
const [view, setViewState] = useState<WorkspaceView>("chat");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [localInferenceOpen, setLocalInferenceOpen] = useState(false);
|
||||
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);
|
||||
const applicationSettingsPendingRef = useRef(0);
|
||||
const applicationSettingsRevisionRef = useRef(0);
|
||||
const [applicationSettingsError, setApplicationSettingsError] = useState<string>();
|
||||
const settingsOpenRef = useRef(false);
|
||||
const [settingsInitialCategory, setSettingsInitialCategory] =
|
||||
useState<SettingsCategoryId>();
|
||||
@@ -2484,6 +2513,10 @@ function App(): React.JSX.Element {
|
||||
(update: SetStateAction<WorkspaceView>): void => {
|
||||
const next =
|
||||
typeof update === "function" ? update(viewRef.current) : update;
|
||||
if (next === "local-inference") {
|
||||
setLocalInferenceOpen(true);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!settingsOpenRef.current &&
|
||||
next === "settings" &&
|
||||
@@ -2569,6 +2602,78 @@ function App(): React.JSX.Element {
|
||||
setMagicNotesShowIncompleteTodoCount,
|
||||
] = 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 =>
|
||||
id === 'knowledge' || id === 'heartbeat' || (applicationNavigation.pinned[id] && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
|
||||
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
|
||||
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
|
||||
setApplicationSettings(settings);
|
||||
setMagicNotesEnabled(settings.magicNotesEnabled);
|
||||
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
|
||||
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
|
||||
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
|
||||
}, []);
|
||||
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
|
||||
const revision = ++applicationSettingsRevisionRef.current;
|
||||
applicationSettingsPendingRef.current++;
|
||||
setApplicationSettingsPending(true);
|
||||
try {
|
||||
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
|
||||
const settings = await window.goodbuddy.updates.getSettings();
|
||||
if (revision !== applicationSettingsRevisionRef.current) return;
|
||||
applyApplicationSettings(settings);
|
||||
setApplicationSettingsUnconfirmed(false);
|
||||
setApplicationSettingsError(undefined);
|
||||
} catch (error) {
|
||||
if (revision !== applicationSettingsRevisionRef.current) return;
|
||||
setApplicationSettingsUnconfirmed(true);
|
||||
setApplicationSettingsError(displayErrorMessage(error, t('applications.loadFailed')));
|
||||
} finally {
|
||||
setApplicationSettingsPending(--applicationSettingsPendingRef.current > 0);
|
||||
}
|
||||
}, [applyApplicationSettings, t]);
|
||||
const updateApplicationSettings = useCallback(async (patch: ApplicationSettingsUpdate): Promise<boolean> => {
|
||||
if (applicationSettingsPendingRef.current || applicationSettingsUnconfirmed) return false;
|
||||
const revision = ++applicationSettingsRevisionRef.current;
|
||||
applicationSettingsPendingRef.current++;
|
||||
setApplicationSettingsPending(true);
|
||||
setApplicationSettingsError(undefined);
|
||||
try {
|
||||
if (!window.goodbuddy.updates) throw new Error(t('applications.loadFailed'));
|
||||
const settings = await window.goodbuddy.updates.updateSettings(patch);
|
||||
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (revision !== applicationSettingsRevisionRef.current) return false;
|
||||
setApplicationSettingsError(displayErrorMessage(error, t('applications.saveFailed')));
|
||||
try {
|
||||
if (!window.goodbuddy.updates) throw error;
|
||||
const settings = await window.goodbuddy.updates.getSettings();
|
||||
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
|
||||
} catch {
|
||||
if (revision !== applicationSettingsRevisionRef.current) return false;
|
||||
// Keep edits locked until Retry obtains the authoritative settings.
|
||||
setApplicationSettingsError(t('applications.confirmFailed'));
|
||||
setApplicationSettingsUnconfirmed(true);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
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);
|
||||
@@ -3144,27 +3249,33 @@ function App(): React.JSX.Element {
|
||||
saveAppearanceTheme(appearanceTheme);
|
||||
}, [appearanceTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const revision = applicationSettingsRevisionRef;
|
||||
const remove = window.goodbuddy.updates?.onSettingsChanged((settings) => {
|
||||
// A persisted change supersedes reads and mutation replies already in flight.
|
||||
revision.current++;
|
||||
applyApplicationSettings(settings);
|
||||
setApplicationSettingsUnconfirmed(false);
|
||||
setApplicationSettingsError(undefined);
|
||||
});
|
||||
return () => {
|
||||
remove?.();
|
||||
revision.current++;
|
||||
};
|
||||
}, [applyApplicationSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const updates = window.goodbuddy.updates;
|
||||
if (!updates || startupUpdateCheckStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
startupUpdateCheckStartedRef.current = true;
|
||||
const revision = applicationSettingsRevisionRef.current;
|
||||
let updateCheckSource: "github" | "mirror" | undefined;
|
||||
void updates
|
||||
.getSettings()
|
||||
.then(async (settings) => {
|
||||
setConversationHtmlRenderingEnabled(
|
||||
settings.conversationHtmlRenderingEnabled !== false,
|
||||
);
|
||||
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
|
||||
setMagicNotesEnabled(settings.magicNotesEnabled);
|
||||
setMagicNotesShowIncompleteTodoCount(
|
||||
settings.magicNotesShowIncompleteTodoCount,
|
||||
);
|
||||
if (!settings.magicNotesEnabled) {
|
||||
setView((current) => (current === "magic-notes" ? "chat" : current));
|
||||
}
|
||||
if (revision === applicationSettingsRevisionRef.current) applyApplicationSettings(settings);
|
||||
if (!settings.checkUpdatesOnStartup) {
|
||||
return;
|
||||
}
|
||||
@@ -3183,6 +3294,8 @@ function App(): React.JSX.Element {
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!updateCheckSource) {
|
||||
if (revision !== applicationSettingsRevisionRef.current) return;
|
||||
setApplicationSettingsError(displayErrorMessage(reason, i18n.t('applications.loadFailed', { ns: 'app' })));
|
||||
return;
|
||||
}
|
||||
notify({
|
||||
@@ -3201,7 +3314,7 @@ function App(): React.JSX.Element {
|
||||
dedupeKey: "startup-update-check",
|
||||
});
|
||||
});
|
||||
}, [i18n, setView]);
|
||||
}, [i18n, applyApplicationSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const magicNotes = window.goodbuddy.magicNotes;
|
||||
@@ -3903,6 +4016,25 @@ function App(): React.JSX.Element {
|
||||
),
|
||||
[assistantTasks],
|
||||
);
|
||||
const executionStatsRevision = useMemo(
|
||||
() => assistantTasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|"),
|
||||
[assistantTasks],
|
||||
);
|
||||
const statsConversation = activeConversation?.projectId === activeProjectId
|
||||
? activeConversation : undefined;
|
||||
const statsMessageCount = statsConversation?.messageSummary?.count ?? statsConversation?.messages.length ?? 0;
|
||||
const executionStats = useExecutionStats(
|
||||
statsConversation?.id,
|
||||
activeProjectId || undefined,
|
||||
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
|
||||
assistantSidebarOpen && assistantSidebarTab === "tasks",
|
||||
);
|
||||
const taskDurations = useMemo(() => new Map(
|
||||
executionStats.project?.taskDurations.map((task) => [task.id, {
|
||||
durationMs: task.durationMs,
|
||||
incomplete: task.incompleteRequestCount > 0,
|
||||
}]) ?? [],
|
||||
), [executionStats.project]);
|
||||
const tasksByConversation = useMemo(() => {
|
||||
const grouped = new Map<string, AssistantTask[]>();
|
||||
for (const task of productAssistantTasks) {
|
||||
@@ -3945,7 +4077,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
|
||||
useUnviewedCompletions(assistantTasks,
|
||||
view === "chat" && !settingsOpen && activeConversation && !activeConversation.messageSummary
|
||||
view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation && !activeConversation.messageSummary
|
||||
? activeId : undefined);
|
||||
const projectActivity = useMemo(
|
||||
() => deriveConversationActivity(
|
||||
@@ -8331,12 +8463,19 @@ function App(): React.JSX.Element {
|
||||
|
||||
const createCustomTask = async (
|
||||
input: Parameters<typeof window.goodbuddy.schedules.create>[0],
|
||||
options?: CustomTaskCreateOptions,
|
||||
): Promise<AssistantSchedule> => {
|
||||
if (!options?.runImmediately && !(Date.parse(input.nextRunAt) > Date.now())) {
|
||||
throw new Error(t("customTask.errors.futureTime"));
|
||||
}
|
||||
if (input.conversationId) {
|
||||
persistLocalConversationChanges();
|
||||
await conversationPersistenceQueueRef.current;
|
||||
}
|
||||
const schedule = await window.goodbuddy.schedules.create(input);
|
||||
const schedule = await window.goodbuddy.schedules.create({
|
||||
...input,
|
||||
runImmediately: options?.runImmediately ?? false,
|
||||
});
|
||||
setAssistantSchedules((current) => [
|
||||
schedule,
|
||||
...current.filter((item) => item.id !== schedule.id),
|
||||
@@ -8682,6 +8821,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<ApplicationSettingsNavigation value={openApplicationSettings}>
|
||||
<div className="app-shell">
|
||||
<aside
|
||||
aria-label={
|
||||
@@ -8797,78 +8937,34 @@ function App(): React.JSX.Element {
|
||||
<MessageSquare aria-hidden="true" size={17} />
|
||||
<span>{t("navigation.chat")}</span>
|
||||
</button>
|
||||
{magicNotesEnabled && (
|
||||
<button
|
||||
aria-current={view === "magic-notes" ? "page" : undefined}
|
||||
className={
|
||||
view === "magic-notes"
|
||||
? "nav-item nav-item--active"
|
||||
: "nav-item"
|
||||
}
|
||||
onFocus={() => preloadWorkspaceRouteOnIntent("magic-notes")}
|
||||
onClick={(event) =>
|
||||
navigateFromSidebar("magic-notes", event.currentTarget)
|
||||
}
|
||||
onPointerEnter={() =>
|
||||
preloadWorkspaceRouteOnIntent("magic-notes")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles aria-hidden="true" size={17} />
|
||||
<span>{t("navigation.magicNotes")}</span>
|
||||
{magicNotesShowIncompleteTodoCount &&
|
||||
incompleteMagicTodoCount > 0 && (
|
||||
<span
|
||||
aria-label={t("navigation.incompleteTodos", {
|
||||
count: incompleteMagicTodoCount,
|
||||
})}
|
||||
className="nav-item__badge"
|
||||
>
|
||||
{incompleteMagicTodoCount > 99
|
||||
? "99+"
|
||||
: incompleteMagicTodoCount}
|
||||
{visibleApplications.map((id) => {
|
||||
const definition = applicationDefinitions[id];
|
||||
const Icon = definition.icon;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={view === id ? "nav-item nav-item--active" : "nav-item"}
|
||||
aria-current={view === id ? "page" : undefined}
|
||||
onClick={(event) => navigateFromSidebar(id, event.currentTarget)}
|
||||
onFocus={() => preloadWorkspaceRouteOnIntent(id)}
|
||||
onPointerEnter={() => preloadWorkspaceRouteOnIntent(id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{t(definition.title)}</span>
|
||||
{id === "magic-notes" && magicNotesShowIncompleteTodoCount && incompleteMagicTodoCount > 0 && (
|
||||
<span className="nav-item__badge" aria-label={t("navigation.incompleteTodos", { count: incompleteMagicTodoCount })}>
|
||||
{incompleteMagicTodoCount > 99 ? "99+" : incompleteMagicTodoCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-current={view === "knowledge" ? "page" : undefined}
|
||||
className={
|
||||
view === "knowledge" ? "nav-item nav-item--active" : "nav-item"
|
||||
}
|
||||
onFocus={() => preloadWorkspaceRouteOnIntent("knowledge")}
|
||||
onClick={(event) =>
|
||||
navigateFromSidebar("knowledge", event.currentTarget)
|
||||
}
|
||||
onPointerEnter={() => preloadWorkspaceRouteOnIntent("knowledge")}
|
||||
type="button"
|
||||
>
|
||||
<Library aria-hidden="true" size={17} />
|
||||
<span>{t("navigation.knowledge")}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-current={view === "heartbeat" ? "page" : undefined}
|
||||
className={
|
||||
view === "heartbeat" ? "nav-item nav-item--active" : "nav-item"
|
||||
}
|
||||
onClick={(event) =>
|
||||
navigateFromSidebar("heartbeat", event.currentTarget)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<HeartPulse aria-hidden="true" size={17} />
|
||||
<span>{t("navigation.heartbeat")}</span>
|
||||
{pendingHeartbeatSuggestionCount > 0 && (
|
||||
<span
|
||||
aria-label={t("navigation.pendingSuggestions", {
|
||||
count: pendingHeartbeatSuggestionCount,
|
||||
})}
|
||||
className="nav-item__badge"
|
||||
>
|
||||
{pendingHeartbeatSuggestionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{id === "heartbeat" && pendingHeartbeatSuggestionCount > 0 && (
|
||||
<span className="nav-item__badge" aria-label={t("navigation.pendingSuggestions", { count: pendingHeartbeatSuggestionCount })}>
|
||||
{pendingHeartbeatSuggestionCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
aria-current={view === "activity" ? "page" : undefined}
|
||||
className={
|
||||
@@ -8881,7 +8977,7 @@ function App(): React.JSX.Element {
|
||||
onPointerEnter={() => preloadWorkspaceRouteOnIntent("activity")}
|
||||
type="button"
|
||||
>
|
||||
<TerminalSquare aria-hidden="true" size={17} />
|
||||
<ChartColumn aria-hidden="true" size={17} />
|
||||
<span>{t("navigation.activity")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -9415,9 +9511,34 @@ function App(): React.JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-footer sidebar-footer--applications">
|
||||
<button
|
||||
className="user-card"
|
||||
className="nav-item"
|
||||
type="button"
|
||||
ref={applicationMenuTriggerRef}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={applicationMenuOpen}
|
||||
aria-controls="application-menu"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
if (!applicationMenuOpen) {
|
||||
setApplicationMenuOpen(true);
|
||||
void reloadApplicationSettings();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
setApplicationMenuOpen(!applicationMenuOpen);
|
||||
if (!applicationMenuOpen) void reloadApplicationSettings();
|
||||
}}
|
||||
>
|
||||
<Grid2X2 size={17} aria-hidden="true" />
|
||||
<span>{t("applications.title")}</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label={t('navigation.settings')}
|
||||
title={t('navigation.settings')}
|
||||
type="button"
|
||||
onFocus={() => preloadWorkspaceRouteOnIntent("settings")}
|
||||
onClick={(event) =>
|
||||
@@ -9425,15 +9546,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
onPointerEnter={() => preloadWorkspaceRouteOnIntent("settings")}
|
||||
>
|
||||
<span className="avatar">GB</span>
|
||||
<span className="user-card__copy">
|
||||
<strong>{t("sidebar.localWorkspace")}</strong>
|
||||
<small>
|
||||
{appInfo
|
||||
? `${appInfo.platform} · ${appInfo.arch}`
|
||||
: t("sidebar.loading")}
|
||||
</small>
|
||||
</span>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -10830,13 +10942,13 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{magicNotesEnabled &&
|
||||
(view === "magic-notes" ||
|
||||
{(view === "magic-notes" ||
|
||||
cachedWorkspaceViewKeys.has("magic-notes")) && (
|
||||
<KeepAliveRoute
|
||||
active={view === "magic-notes"}
|
||||
route="magic-notes"
|
||||
>
|
||||
<ApplicationAvailability id="magic-notes" enabled={magicNotesEnabled}>
|
||||
<PageShell variant="master-detail">
|
||||
<RouteErrorBoundary
|
||||
key="magic-notes"
|
||||
@@ -10852,10 +10964,11 @@ function App(): React.JSX.Element {
|
||||
<RouteLoadingStatus label={t("route.loading")} />
|
||||
}
|
||||
>
|
||||
<MagicNotesWorkspace onNotify={notify} />
|
||||
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</PageShell>
|
||||
</ApplicationAvailability>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{(view === "knowledge" ||
|
||||
@@ -11247,6 +11360,51 @@ function App(): React.JSX.Element {
|
||||
</PageShell>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{localInferenceOpen && (
|
||||
<LocalInferencePage
|
||||
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
||||
onClose={() => setLocalInferenceOpen(false)}
|
||||
restoreFocus={() => applicationMenuTriggerRef.current?.closest('[aria-hidden="true"]')
|
||||
? document.querySelector<HTMLElement>('.sidebar-toggle')
|
||||
: applicationMenuTriggerRef.current}
|
||||
/>
|
||||
)}
|
||||
{applicationCenterOpen && (
|
||||
<ApplicationCenter
|
||||
settings={applicationSettings}
|
||||
pending={applicationSettingsPending}
|
||||
locked={applicationSettingsUnconfirmed}
|
||||
error={applicationSettingsError}
|
||||
initialApplication={applicationSettingsTarget}
|
||||
onClose={() => setApplicationCenterOpen(false)}
|
||||
onOpen={(id) => {
|
||||
if (!isApplicationEnabled(applicationSettings, id)) return;
|
||||
setApplicationCenterOpen(false);
|
||||
setView(id);
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
}}
|
||||
onUpdate={updateApplicationSettings}
|
||||
onRetry={() => void reloadApplicationSettings()}
|
||||
/>
|
||||
)}
|
||||
{applicationMenuOpen && (
|
||||
<ApplicationMenu
|
||||
anchorRef={applicationMenuTriggerRef}
|
||||
settings={applicationSettings}
|
||||
pending={applicationSettingsPending || (!applicationSettings && !applicationSettingsError)}
|
||||
error={applicationSettingsError}
|
||||
onClose={() => setApplicationMenuOpen(false)}
|
||||
onOpen={(id) => {
|
||||
setView(id);
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
}}
|
||||
onManage={() => {
|
||||
setApplicationSettingsTarget(undefined);
|
||||
setApplicationCenterOpen(true);
|
||||
}}
|
||||
onRetry={() => void reloadApplicationSettings()}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<RouteErrorBoundary
|
||||
key="settings"
|
||||
@@ -11303,18 +11461,6 @@ function App(): React.JSX.Element {
|
||||
setSelectedExpertId("");
|
||||
}
|
||||
}}
|
||||
onMagicNotesEnabledChange={(enabled) => {
|
||||
setMagicNotesEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setIncompleteMagicTodoCount(0);
|
||||
}
|
||||
}}
|
||||
onMagicNotesShowIncompleteTodoCountChange={(enabled) => {
|
||||
setMagicNotesShowIncompleteTodoCount(enabled);
|
||||
if (!enabled) {
|
||||
setIncompleteMagicTodoCount(0);
|
||||
}
|
||||
}}
|
||||
onRemoteProjectsEnabledChange={
|
||||
handleRemoteProjectsEnabledChange
|
||||
}
|
||||
@@ -11462,6 +11608,10 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
{customTaskDialog && activeProject?.kind === "user" && (
|
||||
<CustomTaskDialog
|
||||
conversations={conversations.filter(
|
||||
(conversation) => !conversation.remote &&
|
||||
conversation.projectId === activeProject.id,
|
||||
)}
|
||||
currentConversationAvailable={Boolean(
|
||||
activeConversation &&
|
||||
!activeConversation.remote &&
|
||||
@@ -11480,6 +11630,13 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
<RightAssistantSidebar
|
||||
activeConversationId={activeId}
|
||||
conversationStats={statsConversation && executionStats.conversation ? {
|
||||
conversationId: statsConversation.id,
|
||||
messageCount: statsMessageCount,
|
||||
replyDurationMs: executionStats.conversation.durationMs,
|
||||
incomplete: executionStats.conversation.incompleteRequestCount > 0,
|
||||
} : undefined}
|
||||
taskDurations={taskDurations}
|
||||
approvals={pendingSidebarApprovals}
|
||||
artifacts={sidebarArtifacts}
|
||||
browserStates={browserStates}
|
||||
@@ -11559,6 +11716,7 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ApplicationSettingsNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applicationSettingsSchema,
|
||||
defaultApplicationNavigation,
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import {
|
||||
ApplicationAvailability,
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsNavigation,
|
||||
ApplicationSettingsView,
|
||||
} from './ApplicationCenter'
|
||||
|
||||
const settings = applicationSettingsSchema.parse({
|
||||
checkUpdatesOnStartup: false,
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
})
|
||||
const props = () => ({
|
||||
settings,
|
||||
pending: false,
|
||||
onClose: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onUpdate: vi.fn(async () => true),
|
||||
onRetry: vi.fn(),
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Application Center', () => {
|
||||
it('returns from compact settings through header navigation without losing the search', () => {
|
||||
render(<ApplicationCenter {...props()} />)
|
||||
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '笔记' } })
|
||||
const body = screen.getByRole('searchbox').closest('.application-center__body')!
|
||||
body.scrollTop = 200
|
||||
fireEvent.click(screen.getByRole('button', { name: '魔法笔记 应用设置' }))
|
||||
expect(body.scrollTop).toBe(0)
|
||||
const dialog = screen.getByRole('dialog', { name: '魔法笔记' })
|
||||
expect(dialog).toHaveClass('application-center--detail')
|
||||
const back = within(dialog).getByRole('button', { name: '返回应用中心' })
|
||||
expect(back.closest('.settings-panel__header')).not.toBeNull()
|
||||
fireEvent.click(back)
|
||||
expect(screen.getByRole('searchbox')).toHaveValue('笔记')
|
||||
expect(screen.getByRole('searchbox')).toHaveFocus()
|
||||
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) => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
const card = screen.getByText(title, { exact: true }).closest('article')!
|
||||
expect(card).toHaveAttribute('draggable', 'false')
|
||||
expect(within(card).queryByRole('switch')).not.toBeInTheDocument()
|
||||
expect(within(card).getAllByRole('button')).toHaveLength(1)
|
||||
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()
|
||||
})
|
||||
it('searches disabled and unpinned applications, opens without pinning, and restores focus', () => {
|
||||
const handlers = props()
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
trigger.focus()
|
||||
const { unmount } = render(
|
||||
<ApplicationCenter
|
||||
{...handlers}
|
||||
settings={{
|
||||
...settings,
|
||||
localInferenceEnabled: false,
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: {
|
||||
...defaultApplicationNavigation.pinned,
|
||||
'magic-notes': false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const search = screen.getByRole('searchbox', { name: '搜索内置应用' })
|
||||
expect(search).toHaveFocus()
|
||||
fireEvent.change(search, { target: { value: '笔记' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('magic-notes')
|
||||
expect(handlers.onUpdate).not.toHaveBeenCalled()
|
||||
fireEvent.change(search, { target: { value: '推理' } })
|
||||
expect(screen.getByText('已关闭', { exact: false })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开' }),
|
||||
).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
expect(screen.getByRole('switch', { name: '启用应用' })).not.toBeChecked()
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(handlers.onClose).toHaveBeenCalledOnce()
|
||||
unmount()
|
||||
expect(trigger).toHaveFocus()
|
||||
trigger.remove()
|
||||
})
|
||||
|
||||
it('saves complete order and independent pin preferences without optimistic changes', () => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理 应用设置' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: '常驻左侧菜单' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { ...defaultApplicationNavigation.pinned, 'local-inference': false },
|
||||
},
|
||||
})
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '常驻左侧菜单' }),
|
||||
).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '上移 本机推理' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['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()
|
||||
})
|
||||
|
||||
it('uses the same order patch for drag and keyboard controls', () => {
|
||||
const handlers = props()
|
||||
render(<ApplicationCenter {...handlers} />)
|
||||
const notes = screen
|
||||
.getByText('魔法笔记', { exact: true })
|
||||
.closest('article')!
|
||||
const inference = screen
|
||||
.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'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('locks all preference controls while saving or awaiting a confirmed read', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} pending initialApplication="local-inference" />)
|
||||
for (const toggle of screen.getAllByRole('switch'))
|
||||
expect(toggle).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '上移 本机推理' })).toBeDisabled()
|
||||
rerender(
|
||||
<ApplicationCenter {...handlers} locked error="无法确认保存结果" />,
|
||||
)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('无法确认保存结果')
|
||||
for (const toggle of screen.getAllByRole('switch'))
|
||||
expect(toggle).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新读取' }))
|
||||
expect(handlers.onRetry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('provides a recoverable search empty state and no market or system tools', () => {
|
||||
render(<ApplicationCenter {...props()} />)
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('安装')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('终端')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByRole('searchbox'), {
|
||||
target: { value: 'absent' },
|
||||
})
|
||||
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(screen.getAllByRole('article')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('edits all notes preferences through the shared settings form', () => {
|
||||
const onUpdate = vi.fn(async () => true)
|
||||
render(
|
||||
<ApplicationSettingsView
|
||||
id="magic-notes"
|
||||
settings={settings}
|
||||
pending={false}
|
||||
onUpdate={onUpdate}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch', { name: '启用应用' }))
|
||||
expect(onUpdate).toHaveBeenLastCalledWith({ magicNotesEnabled: false })
|
||||
fireEvent.click(screen.getByRole('switch', { name: '显示未完成待办数量' }))
|
||||
expect(onUpdate).toHaveBeenLastCalledWith({
|
||||
magicNotesShowIncompleteTodoCount: false,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存后自动' }))
|
||||
expect(onUpdate).toHaveBeenLastCalledWith({
|
||||
magicNoteCommentMode: 'after-save-auto',
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '要点' }))
|
||||
expect(onUpdate).toHaveBeenLastCalledWith({
|
||||
magicNoteCommentFormat: 'structured',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps disabled page drafts mounted with a recovery entry', () => {
|
||||
const open = vi.fn()
|
||||
const page = (enabled: boolean) => (
|
||||
<ApplicationSettingsNavigation value={open}>
|
||||
<ApplicationAvailability id="local-inference" enabled={enabled}>
|
||||
<input aria-label="draft" defaultValue="original" />
|
||||
</ApplicationAvailability>
|
||||
</ApplicationSettingsNavigation>
|
||||
)
|
||||
const { rerender, container } = render(page(true))
|
||||
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')
|
||||
rerender(page(true))
|
||||
expect(screen.getByRole('textbox')).toHaveValue('keep this draft')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,519 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HeartPulse,
|
||||
Library,
|
||||
Settings,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings,
|
||||
type ApplicationSettingsUpdate,
|
||||
type BuiltInApplicationId,
|
||||
type EditableApplicationId,
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
|
||||
import './application-center.css'
|
||||
|
||||
export const applicationDefinitions = {
|
||||
'magic-notes': {
|
||||
icon: Sparkles,
|
||||
title: 'navigation.magicNotes',
|
||||
enabled: 'magicNotesEnabled',
|
||||
},
|
||||
knowledge: {
|
||||
icon: Library,
|
||||
title: 'navigation.knowledge',
|
||||
},
|
||||
heartbeat: {
|
||||
icon: HeartPulse,
|
||||
title: 'navigation.heartbeat',
|
||||
},
|
||||
'local-inference': {
|
||||
icon: Cpu,
|
||||
title: 'applications.localInference',
|
||||
enabled: 'localInferenceEnabled',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function isApplicationEnabled(
|
||||
settings: ApplicationSettings | undefined,
|
||||
id: BuiltInApplicationId,
|
||||
): boolean {
|
||||
if (id === 'knowledge' || id === 'heartbeat') return true
|
||||
return (
|
||||
settings !== undefined &&
|
||||
settings[applicationDefinitions[id].enabled] !== false
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<div
|
||||
className="application-page-content"
|
||||
hidden={!enabled}
|
||||
inert={!enabled}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApplicationSettingsView({
|
||||
id,
|
||||
settings,
|
||||
pending,
|
||||
onUpdate,
|
||||
}: {
|
||||
id: EditableApplicationId
|
||||
settings: ApplicationSettings
|
||||
pending: boolean
|
||||
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation(['app', 'settingsSections'])
|
||||
const navigation =
|
||||
settings.applicationNavigation ?? defaultApplicationNavigation
|
||||
return (
|
||||
<div className="application-settings">
|
||||
<div className="application-settings__group">
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={isApplicationEnabled(settings, id)}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
void onUpdate({
|
||||
[applicationDefinitions[id].enabled]: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.enable')}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={navigation.pinned[id]}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
void onUpdate({
|
||||
applicationNavigation: {
|
||||
...navigation,
|
||||
pinned: { ...navigation.pinned, [id]: event.target.checked },
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.pin')}</span>
|
||||
</label>
|
||||
<p className="settings-notice">{t('applications.enableHelp')}</p>
|
||||
{id === 'magic-notes' && (
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
checked={settings.magicNotesShowIncompleteTodoCount}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
void onUpdate({
|
||||
magicNotesShowIncompleteTodoCount: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.showIncompleteTodoCount', {
|
||||
ns: 'settingsSections',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{id === 'magic-notes' && (
|
||||
<div className="application-settings__group">
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode', {
|
||||
ns: 'settingsSections',
|
||||
})}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentModeAria', {
|
||||
ns: 'settingsSections',
|
||||
})}
|
||||
disabled={pending}
|
||||
value={settings.magicNoteCommentMode}
|
||||
onChange={(magicNoteCommentMode) =>
|
||||
void onUpdate({ magicNoteCommentMode })
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t('platformFeatures.magicNotes.modes.immediate', {
|
||||
ns: 'settingsSections',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t('platformFeatures.magicNotes.modes.afterSaveAuto', {
|
||||
ns: 'settingsSections',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual',
|
||||
{ ns: 'settingsSections' },
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentFormat', {
|
||||
ns: 'settingsSections',
|
||||
})}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('platformFeatures.magicNotes.commentFormatAria', {
|
||||
ns: 'settingsSections',
|
||||
})}
|
||||
disabled={pending}
|
||||
value={settings.magicNoteCommentFormat}
|
||||
onChange={(magicNoteCommentFormat) =>
|
||||
void onUpdate({ magicNoteCommentFormat })
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t('platformFeatures.magicNotes.formats.combined', {
|
||||
ns: 'settingsSections',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t('platformFeatures.magicNotes.formats.narrative', {
|
||||
ns: 'settingsSections',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t('platformFeatures.magicNotes.formats.structured', {
|
||||
ns: 'settingsSections',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApplicationCenter({
|
||||
settings,
|
||||
pending,
|
||||
locked = false,
|
||||
error,
|
||||
initialApplication,
|
||||
onClose,
|
||||
onOpen,
|
||||
onUpdate,
|
||||
onRetry,
|
||||
}: {
|
||||
settings?: ApplicationSettings
|
||||
pending: boolean
|
||||
locked?: boolean
|
||||
error?: string
|
||||
initialApplication?: EditableApplicationId
|
||||
onClose: () => void
|
||||
onOpen: (id: BuiltInApplicationId) => void
|
||||
onUpdate: (patch: ApplicationSettingsUpdate) => Promise<boolean>
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState(initialApplication)
|
||||
const [dragged, setDragged] = useState<EditableApplicationId>()
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const dialog = useRef<HTMLElement>(null)
|
||||
const body = useRef<HTMLDivElement>(null)
|
||||
const search = useRef<HTMLInputElement>(null)
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
useLayoutEffect(
|
||||
() => activateModalFocus(() => search.current ?? close.current),
|
||||
[],
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (body.current) body.current.scrollTop = 0
|
||||
;(selected ? close.current : search.current)?.focus()
|
||||
}, [selected])
|
||||
const navigation =
|
||||
settings?.applicationNavigation ?? defaultApplicationNavigation
|
||||
const editingDisabled = pending || locked
|
||||
const results = useMemo(
|
||||
() =>
|
||||
['knowledge' as const, 'heartbeat' as const, ...navigation.order].filter((id) =>
|
||||
`${t(applicationDefinitions[id].title)} ${t(`applications.descriptions.${id}`)}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(query.trim().toLocaleLowerCase()),
|
||||
),
|
||||
[navigation.order, query, t],
|
||||
)
|
||||
const move = async (
|
||||
from: EditableApplicationId,
|
||||
to: EditableApplicationId,
|
||||
): Promise<void> => {
|
||||
if (editingDisabled || from === to) return
|
||||
const order = [...navigation.order]
|
||||
order.splice(order.indexOf(from), 1)
|
||||
order.splice(navigation.order.indexOf(to), 0, from)
|
||||
if (await onUpdate({ applicationNavigation: { ...navigation, order } }))
|
||||
setAnnouncement(t('applications.orderUpdated'))
|
||||
}
|
||||
return createPortal(
|
||||
<div className="settings-backdrop" role="presentation">
|
||||
<section
|
||||
className={`settings-panel application-center${selected ? ' application-center--detail' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="application-center-title"
|
||||
id="application-center"
|
||||
ref={dialog}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
trapTabFocus(event, dialog.current)
|
||||
}}
|
||||
>
|
||||
<div className="settings-panel__header">
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label={t('applications.back')}
|
||||
title={t('applications.back')}
|
||||
onClick={() => setSelected(undefined)}
|
||||
>
|
||||
<ArrowLeft size={19} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<PageHeader
|
||||
headingId="application-center-title"
|
||||
title={
|
||||
selected
|
||||
? t(applicationDefinitions[selected].title)
|
||||
: t('applications.title')
|
||||
}
|
||||
description={t(
|
||||
selected
|
||||
? `applications.descriptions.${selected}`
|
||||
: 'applications.description',
|
||||
)}
|
||||
actions={
|
||||
<button
|
||||
ref={close}
|
||||
type="button"
|
||||
className="icon-button"
|
||||
aria-label={t('applications.close')}
|
||||
title={t('applications.close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={19} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={body} className="application-center__body" aria-busy={pending}>
|
||||
{error && (
|
||||
<div role="alert" className="settings-warning">
|
||||
{error}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={pending}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('applications.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="application-center__search">
|
||||
<span>{t('applications.search')}</span>
|
||||
<input
|
||||
ref={search}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{results.length === 0 && (
|
||||
<div>
|
||||
<p>{t('applications.noResults')}</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
{t('applications.clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="application-center__grid">{results.map((id) => {
|
||||
const definition = applicationDefinitions[id]
|
||||
const Icon = definition.icon
|
||||
const enabled = isApplicationEnabled(settings, id)
|
||||
const fixed = id === 'knowledge' || id === 'heartbeat'
|
||||
return (
|
||||
<article
|
||||
className="application-center__item"
|
||||
key={id}
|
||||
draggable={!fixed && !editingDisabled && !query}
|
||||
onDragStart={() => { if (!fixed) setDragged(id) }}
|
||||
onDragEnd={() => setDragged(undefined)}
|
||||
onDragOver={(event) => {
|
||||
if (!fixed && dragged && !editingDisabled) event.preventDefault()
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
if (!fixed && dragged) void move(dragged, id)
|
||||
setDragged(undefined)
|
||||
}}
|
||||
>
|
||||
<div className="application-center__summary">
|
||||
<Icon size={22} aria-hidden="true" />
|
||||
<div>
|
||||
<div className="application-center__title">
|
||||
<strong>{t(definition.title)}</strong>
|
||||
<small>
|
||||
{fixed
|
||||
? t('applications.fixed')
|
||||
: `${t('applications.optional')} · ${t(enabled ? 'applications.enabled' : 'applications.disabled')}`}
|
||||
</small>
|
||||
</div>
|
||||
<p>{t(`applications.descriptions.${id}`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="application-center__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => onOpen(id)}
|
||||
>
|
||||
{t('applications.open')}
|
||||
</button>
|
||||
{!fixed && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
title={t('applications.settings')}
|
||||
aria-label={`${t(definition.title)} ${t('applications.settings')}`}
|
||||
onClick={() => setSelected(id)}
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
{t('applications.settings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}</div>
|
||||
</>
|
||||
)}
|
||||
<span className="sr-only" role="status">{announcement}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings, type ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import { ApplicationMenu } from './ApplicationMenu'
|
||||
import { isBrowserViewportOccluded } from './browser-viewport-occlusion'
|
||||
|
||||
const settings = applicationSettingsSchema.parse({
|
||||
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
|
||||
})
|
||||
function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), onManage = vi.fn(), onRetry = vi.fn() }: {
|
||||
value?: ApplicationSettings | null; pending?: boolean; error?: string
|
||||
onOpen?: (id: string) => void; onManage?: () => void; onRetry?: () => void
|
||||
}): React.JSX.Element {
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
return <><button ref={anchorRef} onClick={() => setOpen(!open)}>Launcher</button><button>Outside</button>
|
||||
{open && <ApplicationMenu anchorRef={anchorRef} settings={value ?? undefined} pending={pending} error={error}
|
||||
onClose={() => setOpen(false)} onOpen={onOpen} onManage={onManage} onRetry={onRetry} />}</>
|
||||
}
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it('lists enabled apps regardless of pins and keeps Knowledge and Heartbeat fixed before filtering', () => {
|
||||
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true,
|
||||
applicationNavigation: { order: ['local-inference', 'magic-notes'] 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(['知识库', '智能心跳', '本机推理', '魔法笔记', '管理应用'])
|
||||
rerender(<Harness value={{ ...value, magicNotesEnabled: false }} onOpen={onOpen} />)
|
||||
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()
|
||||
})
|
||||
|
||||
it('shows loading and actionable errors without treating unknown settings as disabled', () => {
|
||||
const onRetry = vi.fn()
|
||||
const { rerender } = render(<Harness value={null} pending onRetry={onRetry} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: '知识库' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: '智能心跳' })).toBeInTheDocument()
|
||||
rerender(<Harness value={null} error="Cannot read settings" onRetry={onRetry} />)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Cannot read settings')
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重新读取' }))
|
||||
expect(onRetry).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('supports arrow, Home, End, Escape, outside focus and pointer dismissal and management focus handoff', () => {
|
||||
const onManage = vi.fn()
|
||||
render(<Harness onManage={onManage} />)
|
||||
const trigger = screen.getByText('Launcher')
|
||||
fireEvent.click(trigger)
|
||||
const items = screen.getAllByRole('menuitem')
|
||||
expect(items[0]).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'End' })
|
||||
expect(items.at(-1)).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' })
|
||||
expect(items[0]).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' })
|
||||
expect(items.at(-1)).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Home' })
|
||||
expect(items[0]).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(trigger).toHaveFocus()
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
act(() => screen.getByText('Outside').focus())
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Outside')).toHaveFocus()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(screen.getByText('Outside'))
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(onManage).toHaveBeenCalledOnce()
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('anchors upward, clamps to a narrow viewport, repositions on resize and uses shared native viewport occlusion', () => {
|
||||
vi.stubGlobal('innerWidth', 320)
|
||||
vi.stubGlobal('innerHeight', 400)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
return this.getAttribute('role') === 'menu' ? new DOMRect(24, 92, 280, 200) : new DOMRect(270, 300, 34, 34)
|
||||
})
|
||||
render(<Harness />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu).toHaveStyle({ left: '24px', top: '92px', width: '280px', maxHeight: '276px' })
|
||||
const host = screen.getByText('Outside')
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(true)
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(400, 100, 100, 100))).toBe(false)
|
||||
vi.stubGlobal('innerWidth', 240)
|
||||
vi.stubGlobal('innerHeight', 220)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(menu).toHaveStyle({ left: '16px', top: '16px', width: '208px', maxHeight: '188px' })
|
||||
fireEvent.keyDown(menu, { key: 'Tab' })
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(200, 100, 100, 100))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo, type RefObject } from 'react'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
|
||||
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
|
||||
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
|
||||
anchorRef: RefObject<HTMLButtonElement | null>
|
||||
settings?: ApplicationSettings
|
||||
pending: boolean
|
||||
error?: string
|
||||
onClose: () => void
|
||||
onOpen: (id: BuiltInApplicationId) => void
|
||||
onManage: () => void
|
||||
onRetry: () => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const applications = useMemo(() => {
|
||||
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
|
||||
return ['knowledge' as const, 'heartbeat' as const, ...order]
|
||||
.filter(id => isApplicationEnabled(settings, id))
|
||||
}, [settings])
|
||||
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
|
||||
{pending && <p role="status">{t('route.loading')}</p>}
|
||||
{error && <>
|
||||
<p role="alert">{error}</p>
|
||||
<button type="button" role="menuitem" tabIndex={-1} disabled={pending} onClick={onRetry}>{t('applications.retry')}</button>
|
||||
</>}
|
||||
{applications.map(id => {
|
||||
const definition = applicationDefinitions[id]
|
||||
const Icon = definition.icon
|
||||
return <button key={id} type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); onOpen(id) }}>
|
||||
<Icon size={16} aria-hidden="true" /><span>{t(definition.title)}</span>
|
||||
</button>
|
||||
})}
|
||||
<div role="separator" />
|
||||
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
|
||||
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
|
||||
</button>
|
||||
</AnchoredMenu>
|
||||
}
|
||||
@@ -69,18 +69,16 @@ describe('CustomTaskDialog', () => {
|
||||
screen.getByRole('dialog', { name: '新建定制任务' })
|
||||
).toHaveAttribute('aria-modal', 'true')
|
||||
expect(screen.queryByRole('button', { name: 'Execute' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getByLabelText('任务名称')).toHaveFocus()
|
||||
expect(screen.getByLabelText('关联会话')).toHaveValue('current')
|
||||
expect(screen.getByLabelText('任务内容')).toHaveFocus()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('任务名称'), {
|
||||
target: { value: '每周项目总结' }
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('任务要求'), {
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), {
|
||||
target: { value: '总结完成和失败的工作' }
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
|
||||
fireEvent.change(screen.getByLabelText('首次运行'), {
|
||||
target: { value: futureLocalDateTime() }
|
||||
})
|
||||
@@ -93,7 +91,8 @@ describe('CustomTaskDialog', () => {
|
||||
title: '每周项目总结',
|
||||
prompt: '总结完成和失败的工作',
|
||||
recurrence: 'once'
|
||||
})
|
||||
}),
|
||||
{ runImmediately: false }
|
||||
)
|
||||
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('workMode')
|
||||
expect(onCreate.mock.calls[0]![0]).not.toHaveProperty('runtimeSelection')
|
||||
@@ -112,6 +111,154 @@ describe('CustomTaskDialog', () => {
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Ask' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
|
||||
expect(screen.getByRole('option', { name: '当前会话' })).toBeDisabled()
|
||||
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
|
||||
})
|
||||
|
||||
const conversationId = '00000000-0000-4000-8000-000000000801'
|
||||
const projectId = '00000000-0000-4000-8000-000000000804'
|
||||
const otherId = '00000000-0000-4000-8000-000000000805'
|
||||
const props = {
|
||||
currentConversationAvailable: true,
|
||||
currentConversationId: conversationId,
|
||||
projectId,
|
||||
projectName: 'GoodBuddy Desktop',
|
||||
workspaceLabel: 'C:\\Workspace',
|
||||
onClose: vi.fn(),
|
||||
onCreate: vi.fn()
|
||||
}
|
||||
|
||||
it('requires content and generates a bounded name for immediate execution by default', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
|
||||
expect(screen.getByRole('button', { name: '立即执行' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.queryByLabelText('首次运行')).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('运行频率')).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: ' \n ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('任务内容')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('请输入任务内容。')
|
||||
|
||||
const content = ` 检查项目\n ${'内容'.repeat(80)} `
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: content } })
|
||||
const before = Date.now()
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
|
||||
const [input, options] = onCreate.mock.calls[0]!
|
||||
expect(input).toMatchObject({
|
||||
projectId,
|
||||
conversationId,
|
||||
title: content.trim().replace(/\s+/gu, ' ').slice(0, 120),
|
||||
prompt: content.trim(),
|
||||
recurrence: 'once'
|
||||
})
|
||||
expect(Date.parse(input.nextRunAt)).toBeGreaterThanOrEqual(before)
|
||||
expect(Date.parse(input.nextRunAt)).toBeLessThanOrEqual(Date.now())
|
||||
expect(options).toEqual({ runImmediately: true })
|
||||
})
|
||||
|
||||
it.each(['daily', 'weekly'])('preserves %s scheduling and delegates future-time validation to App', async (recurrence) => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CustomTaskDialog {...props} onCreate={onCreate} />)
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
|
||||
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: recurrence } })
|
||||
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('首次运行')).toHaveAttribute('aria-invalid', 'true')
|
||||
fireEvent.change(screen.getByLabelText('首次运行'), { target: { value: '2020-01-01T12:00' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
|
||||
expect(screen.getByLabelText('运行频率')).toHaveValue(recurrence)
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ recurrence, nextRunAt: new Date('2020-01-01T12:00').toISOString() }),
|
||||
{ runImmediately: false }
|
||||
))
|
||||
})
|
||||
|
||||
it('only offers other conversations in this project and submits the chosen conversation', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[
|
||||
{ id: conversationId, projectId, title: '当前项目会话' },
|
||||
{ id: otherId, projectId, title: '另一个会话' },
|
||||
{ id: 'elsewhere', projectId: 'another-project', title: '其他项目会话' }
|
||||
]} />)
|
||||
expect(screen.queryByRole('option', { name: '其他项目会话' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '当前项目会话' })).not.toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
|
||||
fireEvent.change(screen.getByLabelText('任务名称'), { target: { value: ' 自定义名称 ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ conversationId: otherId, title: '自定义名称' }),
|
||||
{ runImmediately: true }
|
||||
))
|
||||
})
|
||||
|
||||
it('rejects a removed conversation without silently creating a new one', () => {
|
||||
const onCreate = vi.fn()
|
||||
const { rerender } = render(<CustomTaskDialog {...props} onCreate={onCreate}
|
||||
conversations={[{ id: otherId, projectId, title: '另一个会话' }]} />)
|
||||
fireEvent.change(screen.getByLabelText('关联会话'), { target: { value: otherId } })
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
|
||||
rerender(<CustomTaskDialog {...props} onCreate={onCreate} conversations={[]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
expect(screen.getByLabelText('关联会话')).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
|
||||
it('creates a new conversation without capturing execution settings or hidden recurrence', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CustomTaskDialog {...props} currentConversationId={undefined} onCreate={onCreate} />)
|
||||
expect(screen.getByLabelText('关联会话')).toHaveValue('new')
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '检查项目' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '定时执行' }))
|
||||
fireEvent.change(screen.getByLabelText('运行频率'), { target: { value: 'weekly' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '立即执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
|
||||
expect(onCreate.mock.calls[0]![0]).toEqual({
|
||||
projectId, title: '检查项目', prompt: '检查项目', recurrence: 'once', nextRunAt: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('locks submission and dismissal while pending, then preserves the draft on failure', async () => {
|
||||
let rejectCreate!: (reason: Error) => void
|
||||
const onCreate = vi.fn(() => new Promise<never>((_, reject) => { rejectCreate = reject }))
|
||||
const onClose = vi.fn()
|
||||
render(<CustomTaskDialog {...props} onCreate={onCreate} onClose={onClose} />)
|
||||
fireEvent.change(screen.getByLabelText('任务内容'), { target: { value: '保留草稿' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
|
||||
expect(screen.getByLabelText('任务内容')).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建中…' }))
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(onCreate).toHaveBeenCalledOnce()
|
||||
rejectCreate(new Error('请选择未来时间'))
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('请选择未来时间'))
|
||||
expect(screen.getByLabelText('任务内容')).toHaveValue('保留草稿')
|
||||
expect(screen.getByLabelText('任务内容')).toBeEnabled()
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('portals to the body, traps focus and restores the opener on close', () => {
|
||||
const opener = document.createElement('button')
|
||||
document.body.append(opener)
|
||||
opener.focus()
|
||||
const { unmount, container } = render(<CustomTaskDialog {...props} />)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(container).not.toContainElement(dialog)
|
||||
const create = screen.getByRole('button', { name: '创建任务' })
|
||||
create.focus()
|
||||
fireEvent.keyDown(create, { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭新建定制任务' })).toHaveFocus()
|
||||
unmount()
|
||||
expect(opener).toHaveFocus()
|
||||
opener.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,18 +8,32 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { SegmentedControl } from './WorkspacePrimitives'
|
||||
import './custom-task-dialog.css'
|
||||
|
||||
export type CustomTaskDestination = 'current' | 'new'
|
||||
// App translates the UI execution choice into the schedule creation request.
|
||||
export type CustomTaskCreateOptions = { runImmediately?: boolean }
|
||||
export type CustomTaskConversation = {
|
||||
id: string
|
||||
title: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
const emptyConversations: readonly CustomTaskConversation[] = []
|
||||
|
||||
type CustomTaskDialogProps = {
|
||||
currentConversationAvailable: boolean
|
||||
currentConversationId?: string
|
||||
defaultDestination: CustomTaskDestination
|
||||
defaultDestination?: CustomTaskDestination
|
||||
conversations?: readonly CustomTaskConversation[]
|
||||
projectId?: string
|
||||
projectName: string
|
||||
workspaceLabel: string
|
||||
onClose: () => void
|
||||
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
|
||||
onCreate: (
|
||||
input: ScheduleCreateInput,
|
||||
options?: CustomTaskCreateOptions
|
||||
) => Promise<AssistantSchedule>
|
||||
}
|
||||
|
||||
function toLocalDateTimeValue(date: Date): string {
|
||||
@@ -30,7 +44,8 @@ function toLocalDateTimeValue(date: Date): string {
|
||||
export function CustomTaskDialog({
|
||||
currentConversationAvailable,
|
||||
currentConversationId,
|
||||
defaultDestination,
|
||||
defaultDestination = 'current',
|
||||
conversations = emptyConversations,
|
||||
projectId,
|
||||
projectName,
|
||||
workspaceLabel,
|
||||
@@ -39,81 +54,80 @@ export function CustomTaskDialog({
|
||||
}: CustomTaskDialogProps): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null)
|
||||
const submittingRef = useRef(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [destination, setDestination] = useState<CustomTaskDestination>(
|
||||
defaultDestination === 'current' && currentConversationAvailable
|
||||
const [destination, setDestination] = useState<string>(
|
||||
defaultDestination === 'current' &&
|
||||
currentConversationAvailable && currentConversationId
|
||||
? 'current'
|
||||
: 'new'
|
||||
)
|
||||
const [timing, setTiming] = useState<'now' | 'scheduled'>('now')
|
||||
const [recurrence, setRecurrence] =
|
||||
useState<ScheduleCreateInput['recurrence']>('once')
|
||||
const [nextRunAt, setNextRunAt] = useState(() =>
|
||||
toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000))
|
||||
)
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<'title' | 'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
|
||||
Partial<Record<'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
|
||||
>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(
|
||||
() => activateModalFocus(() => titleRef.current),
|
||||
() => activateModalFocus(() => promptRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const destinationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'current' as const,
|
||||
label: t('customTask.destination.current'),
|
||||
disabled: !currentConversationAvailable
|
||||
},
|
||||
{
|
||||
value: 'new' as const,
|
||||
label: t('customTask.destination.new')
|
||||
}
|
||||
],
|
||||
[currentConversationAvailable, t]
|
||||
const otherConversations = useMemo(
|
||||
() => conversations.filter((conversation) =>
|
||||
conversation.projectId === projectId &&
|
||||
conversation.id !== currentConversationId
|
||||
),
|
||||
[conversations, projectId, currentConversationId]
|
||||
)
|
||||
const generatedTitle = prompt.trim().replace(/\s+/gu, ' ').slice(0, 120)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
if (submittingRef.current) return
|
||||
const nextErrors: typeof errors = {}
|
||||
if (!title.trim()) {
|
||||
nextErrors.title = t('customTask.errors.title')
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
nextErrors.prompt = t('customTask.errors.instructions')
|
||||
}
|
||||
if (
|
||||
destination === 'current' &&
|
||||
(!currentConversationAvailable || !currentConversationId)
|
||||
(destination === 'current' &&
|
||||
(!currentConversationAvailable || !currentConversationId)) ||
|
||||
(destination !== 'current' && destination !== 'new' &&
|
||||
!otherConversations.some((conversation) => conversation.id === destination))
|
||||
) {
|
||||
nextErrors.destination = t('customTask.errors.destination')
|
||||
}
|
||||
const runAt = new Date(nextRunAt)
|
||||
if (!nextRunAt || Number.isNaN(runAt.getTime())) {
|
||||
const runAt = timing === 'now' ? new Date() : new Date(nextRunAt)
|
||||
if (Number.isNaN(runAt.getTime())) {
|
||||
nextErrors.nextRunAt = t('customTask.errors.time')
|
||||
} else if (runAt.getTime() <= Date.now()) {
|
||||
nextErrors.nextRunAt = t('customTask.errors.futureTime')
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreate({
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(destination === 'current' && currentConversationId
|
||||
? { conversationId: currentConversationId }
|
||||
: {}),
|
||||
title: title.trim(),
|
||||
prompt: prompt.trim(),
|
||||
recurrence,
|
||||
nextRunAt: runAt.toISOString()
|
||||
})
|
||||
await onCreate(
|
||||
{
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(destination !== 'new'
|
||||
? { conversationId: destination === 'current' ? currentConversationId : destination }
|
||||
: {}),
|
||||
title: title.trim() || generatedTitle,
|
||||
prompt: prompt.trim(),
|
||||
recurrence: timing === 'now' ? 'once' : recurrence,
|
||||
nextRunAt: runAt.toISOString()
|
||||
},
|
||||
{ runImmediately: timing === 'now' }
|
||||
)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setErrors((current) => ({
|
||||
@@ -124,6 +138,7 @@ export function CustomTaskDialog({
|
||||
: t('customTask.errors.create')
|
||||
}))
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
@@ -176,38 +191,24 @@ export function CustomTaskDialog({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="custom-task-dialog__content">
|
||||
<fieldset
|
||||
className="custom-task-dialog__content custom-task-dialog__form"
|
||||
disabled={submitting}
|
||||
>
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.name')}</span>
|
||||
<input
|
||||
aria-describedby={errors.title ? 'custom-task-title-error' : undefined}
|
||||
aria-invalid={Boolean(errors.title)}
|
||||
maxLength={120}
|
||||
onChange={(event) => {
|
||||
setTitle(event.target.value)
|
||||
setErrors((current) => ({ ...current, title: undefined }))
|
||||
}}
|
||||
ref={titleRef}
|
||||
value={title}
|
||||
/>
|
||||
{errors.title && (
|
||||
<small id="custom-task-title-error" role="alert">
|
||||
{errors.title}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.instructions')}</span>
|
||||
<span id="custom-task-prompt-label">{t('customTask.fields.instructions')}</span>
|
||||
<textarea
|
||||
aria-labelledby="custom-task-prompt-label"
|
||||
aria-describedby={errors.prompt ? 'custom-task-prompt-error' : undefined}
|
||||
aria-invalid={Boolean(errors.prompt)}
|
||||
aria-required="true"
|
||||
maxLength={100_000}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value)
|
||||
setErrors((current) => ({ ...current, prompt: undefined }))
|
||||
}}
|
||||
rows={5}
|
||||
ref={promptRef}
|
||||
value={prompt}
|
||||
/>
|
||||
{errors.prompt && (
|
||||
@@ -217,73 +218,114 @@ export function CustomTaskDialog({
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className="custom-task-dialog__choice">
|
||||
<span>{t('customTask.fields.destination')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('customTask.fields.destination')}
|
||||
onChange={(value) => {
|
||||
setDestination(value)
|
||||
<label className="custom-task-dialog__field">
|
||||
<span id="custom-task-name-label">{t('customTask.fields.name')}</span>
|
||||
<input
|
||||
aria-labelledby="custom-task-name-label"
|
||||
aria-describedby="custom-task-name-help"
|
||||
maxLength={120}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={generatedTitle}
|
||||
value={title}
|
||||
/>
|
||||
<small id="custom-task-name-help">{t('customTask.nameHelp')}</small>
|
||||
</label>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span id="custom-task-destination-label">{t('customTask.fields.destination')}</span>
|
||||
<select
|
||||
aria-labelledby="custom-task-destination-label"
|
||||
aria-describedby={errors.destination ? 'custom-task-destination-error' : 'custom-task-destination-help'}
|
||||
aria-invalid={Boolean(errors.destination)}
|
||||
onChange={(event) => {
|
||||
setDestination(event.target.value)
|
||||
setErrors((current) => ({
|
||||
...current,
|
||||
destination: undefined
|
||||
}))
|
||||
}}
|
||||
options={destinationOptions}
|
||||
value={destination}
|
||||
/>
|
||||
<small>
|
||||
{destination === 'current'
|
||||
? t('customTask.destination.currentHelp')
|
||||
: t('customTask.destination.newHelp')}
|
||||
>
|
||||
<option value="current" disabled={!currentConversationAvailable || !currentConversationId}>
|
||||
{t('customTask.destination.current')}
|
||||
</option>
|
||||
{otherConversations.map((conversation) => (
|
||||
<option key={conversation.id} value={conversation.id}>{conversation.title}</option>
|
||||
))}
|
||||
<option value="new">{t('customTask.destination.new')}</option>
|
||||
</select>
|
||||
<small id="custom-task-destination-help">
|
||||
{destination === 'new'
|
||||
? t('customTask.destination.newHelp')
|
||||
: t('customTask.destination.currentHelp')}
|
||||
</small>
|
||||
{!currentConversationAvailable && (
|
||||
{(!currentConversationAvailable || !currentConversationId) && (
|
||||
<small>{t('customTask.destination.currentUnavailable')}</small>
|
||||
)}
|
||||
{errors.destination && (
|
||||
<small role="alert">{errors.destination}</small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="custom-task-dialog__two-columns">
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.recurrence')}</span>
|
||||
<select
|
||||
onChange={(event) =>
|
||||
setRecurrence(
|
||||
event.target.value as ScheduleCreateInput['recurrence']
|
||||
)
|
||||
}
|
||||
value={recurrence}
|
||||
>
|
||||
<option value="once">{t('customTask.recurrence.once')}</option>
|
||||
<option value="daily">{t('customTask.recurrence.daily')}</option>
|
||||
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.time')}</span>
|
||||
<input
|
||||
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
|
||||
aria-invalid={Boolean(errors.nextRunAt)}
|
||||
onChange={(event) => {
|
||||
setNextRunAt(event.target.value)
|
||||
setErrors((current) => ({
|
||||
...current,
|
||||
nextRunAt: undefined
|
||||
}))
|
||||
}}
|
||||
type="datetime-local"
|
||||
value={nextRunAt}
|
||||
/>
|
||||
{errors.nextRunAt && (
|
||||
<small id="custom-task-time-error" role="alert">
|
||||
{errors.nextRunAt}
|
||||
</small>
|
||||
<small id="custom-task-destination-error" role="alert">{errors.destination}</small>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className="custom-task-dialog__choice">
|
||||
<span>{t('customTask.fields.timing')}</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('customTask.fields.timing')}
|
||||
onChange={(value) => {
|
||||
setTiming(value)
|
||||
setErrors((current) => ({ ...current, nextRunAt: undefined }))
|
||||
}}
|
||||
options={[
|
||||
{ value: 'now', label: t('customTask.timing.now'), disabled: submitting },
|
||||
{ value: 'scheduled', label: t('customTask.timing.scheduled'), disabled: submitting }
|
||||
]}
|
||||
value={timing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{timing === 'scheduled' && (
|
||||
<>
|
||||
<label className="custom-task-dialog__field">
|
||||
<span>{t('customTask.fields.recurrence')}</span>
|
||||
<select
|
||||
onChange={(event) =>
|
||||
setRecurrence(
|
||||
event.target.value as ScheduleCreateInput['recurrence']
|
||||
)
|
||||
}
|
||||
value={recurrence}
|
||||
>
|
||||
<option value="once">{t('customTask.recurrence.once')}</option>
|
||||
<option value="daily">{t('customTask.recurrence.daily')}</option>
|
||||
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="custom-task-dialog__field">
|
||||
<span id="custom-task-time-label">{t('customTask.fields.time')}</span>
|
||||
<input
|
||||
aria-labelledby="custom-task-time-label"
|
||||
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
|
||||
aria-invalid={Boolean(errors.nextRunAt)}
|
||||
onChange={(event) => {
|
||||
setNextRunAt(event.target.value)
|
||||
setErrors((current) => ({
|
||||
...current,
|
||||
nextRunAt: undefined
|
||||
}))
|
||||
}}
|
||||
type="datetime-local"
|
||||
value={nextRunAt}
|
||||
/>
|
||||
{errors.nextRunAt && (
|
||||
<small id="custom-task-time-error" role="alert">
|
||||
{errors.nextRunAt}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-label={t('customTask.scope.title')}
|
||||
className="custom-task-dialog__scope"
|
||||
@@ -316,7 +358,7 @@ export function CustomTaskDialog({
|
||||
{errors.form}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<footer className="custom-task-dialog__actions">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LocalInferencePage from './LocalInferencePage'
|
||||
import type { LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
|
||||
import * as ocrBridge from './document-ocr-bridge'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const styles = readFileSync(join(process.cwd(), 'src/renderer/src/local-inference.css'), 'utf8')
|
||||
|
||||
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() }
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
api.getSnapshot.mockResolvedValue(snapshot)
|
||||
api.act.mockResolvedValue(undefined)
|
||||
api.cancel.mockResolvedValue(undefined)
|
||||
vi.stubGlobal('goodbuddy', { localInference: api })
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('LocalInferencePage', () => {
|
||||
it('shows services directly with truthful resource availability and no task history or external endpoints', async () => {
|
||||
const name = 'A very long inference source and model service name '.repeat(8).trim()
|
||||
api.getSnapshot.mockResolvedValue({
|
||||
...snapshot,
|
||||
tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'finished', source: '历史任务', state: 'completed', finishedAt: 2 }],
|
||||
services: [{ ...snapshot.services[0], name }],
|
||||
externalConnections: [{ id: 'external', name: '外部连接', model: 'remote' }],
|
||||
})
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
const serviceHeading = await screen.findByRole('heading', { name })
|
||||
expect(serviceHeading).not.toHaveTextContent('运行中')
|
||||
expect(within(serviceHeading.parentElement!).getByText('运行中')).toBeInTheDocument()
|
||||
expect(screen.getByText('CPU / 内存:暂不可用')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('知识索引')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('外部连接')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '取消任务' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '重启服务' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps the service toolbar responsive', async () => {
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
const toolbar = screen.getByText('每 5 秒刷新 · CPU 100% = 一个逻辑核心').parentElement!
|
||||
expect(toolbar).toHaveClass('local-inference-toolbar')
|
||||
expect(within(toolbar).getByRole('button', { name: '刷新状态' })).toBeInTheDocument()
|
||||
expect(styles).toMatch(/\.local-inference-toolbar\s*\{[^}]*flex: 0 0 auto/)
|
||||
expect(screen.getByRole('region', { name: '本机推理服务' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not present an active ASR request as a running service', async () => {
|
||||
api.getSnapshot.mockResolvedValue({
|
||||
services: [{ ...snapshot.services[0], id: 'asr', name: '语音识别', state: 'unknown', actions: [] }],
|
||||
tasks: [{ ...snapshot.tasks[0], serviceId: 'asr' }]
|
||||
})
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
expect(await screen.findByText('按需运行(状态未知)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('运行中')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows process scope, measured units and shared-worker unavailability per service', async () => {
|
||||
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [
|
||||
{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 42, sampledAt: 1, cpuPercent: 150, workingSetBytes: 2097152 } },
|
||||
{ ...snapshot.services[0], id: 'asr', name: '语音识别', resources: { scope: 'unavailable', reason: '识别线程与主进程共享资源,无法独立统计 CPU / 内存' } }
|
||||
] })
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
expect(await screen.findByText('服务进程 PID 42 · CPU 150.0% · 工作集 2.0 MiB')).toBeInTheDocument()
|
||||
expect(screen.getByText('CPU / 内存:识别线程与主进程共享资源,无法独立统计 CPU / 内存')).toBeInTheDocument()
|
||||
api.getSnapshot.mockResolvedValue({ ...snapshot, services: [{ ...snapshot.services[0], resources: { scope: 'service-process', pid: 43, sampledAt: 2, cpuUnavailableReason: '正在建立采样基线', workingSetBytes: 1048576 } }] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
|
||||
expect(await screen.findByText('服务进程 PID 43 · CPU 正在建立采样基线 · 工作集 1.0 MiB')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/PID 42/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks service actions on stale data until refresh succeeds', async () => {
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
api.getSnapshot.mockRejectedValueOnce(new Error('offline'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('上次成功读取')
|
||||
expect(screen.getByRole('button', { name: '停止服务' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
|
||||
expect(api.act).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only shows affected active tasks when confirming service stop', async () => {
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '停止服务:向量生成' })
|
||||
expect(within(dialog).getByText(/知识索引/)).toBeInTheDocument()
|
||||
expect(api.act).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认停止服务' }))
|
||||
await waitFor(() => expect(api.act).toHaveBeenCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task'] }))
|
||||
})
|
||||
|
||||
it('recovers from initial load failure and does not stop anything on unmount', async () => {
|
||||
api.getSnapshot.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ services: [], tasks: [] })
|
||||
const page = render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('offline')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByText('暂无本机推理服务')).toBeInTheDocument()
|
||||
page.unmount()
|
||||
expect(api.act).not.toHaveBeenCalled()
|
||||
expect(api.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes after a rejected confirmation and requires confirmation of the new affected tasks', async () => {
|
||||
api.act.mockRejectedValueOnce(new Error('任务已变化'))
|
||||
render(<LocalInferencePage onClose={vi.fn()} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
|
||||
api.getSnapshot.mockResolvedValue({ ...snapshot, tasks: [...snapshot.tasks, { ...snapshot.tasks[0], id: 'new-task', source: '新索引' }] })
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('任务已变化')
|
||||
expect(screen.queryByRole('dialog', { name: '停止服务:向量生成' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止服务' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
|
||||
expect(screen.getByText(/新索引/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认停止服务' }))
|
||||
await waitFor(() => expect(api.act).toHaveBeenLastCalledWith({ serviceId: 'embedding', action: 'stop', confirmedTaskIds: ['real-task', 'new-task'] }))
|
||||
})
|
||||
|
||||
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()
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
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()
|
||||
const stop = screen.getByRole('button', { name: '停止服务' })
|
||||
stop.focus()
|
||||
fireEvent.click(stop)
|
||||
const cancel = screen.getByRole('button', { name: '保留运行状态' })
|
||||
const confirm = screen.getByRole('button', { name: '确认停止服务' })
|
||||
expect(cancel).toHaveFocus()
|
||||
expect(confirm).toHaveClass('danger-button')
|
||||
fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true })
|
||||
expect(confirm).toHaveFocus()
|
||||
fireEvent.keyDown(confirm, { key: 'Tab' })
|
||||
expect(cancel).toHaveFocus()
|
||||
fireEvent.keyDown(cancel, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(stop).toHaveFocus()
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1)
|
||||
fireEvent.keyDown(stop, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks dismissal and duplicate execution while a service operation is pending', async () => {
|
||||
let resolve!: () => void
|
||||
api.act.mockReturnValue(new Promise<void>((done) => { resolve = done }))
|
||||
const onClose = vi.fn()
|
||||
render(<LocalInferencePage onClose={onClose} />)
|
||||
await screen.findByRole('heading', { name: '向量生成' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止服务' }))
|
||||
const confirm = screen.getByRole('button', { name: '确认停止服务' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.keyDown(confirm, { key: 'Escape' })
|
||||
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()
|
||||
})
|
||||
|
||||
it('restores fallback focus when the entry was removed and clears background isolation', () => {
|
||||
const background = document.createElement('div')
|
||||
background.className = 'app-shell'
|
||||
const entry = document.createElement('button')
|
||||
const fallback = document.createElement('button')
|
||||
background.append(entry, fallback)
|
||||
document.body.append(background)
|
||||
entry.focus()
|
||||
const modal = render(<LocalInferencePage onClose={vi.fn()} restoreFocus={() => fallback} />)
|
||||
expect(background.inert).toBe(true)
|
||||
entry.remove()
|
||||
modal.unmount()
|
||||
expect(background.inert).toBe(false)
|
||||
expect(fallback).toHaveFocus()
|
||||
background.remove()
|
||||
})
|
||||
|
||||
it('confirms OCR release with a specific danger action and leaves the management modal open', async () => {
|
||||
vi.spyOn(ocrBridge, 'getOcrInferenceState').mockReturnValue({ state: 'running', loaded: true, busy: false })
|
||||
const release = vi.spyOn(ocrBridge, 'releaseOcrInference').mockImplementation(() => undefined)
|
||||
api.getSnapshot.mockResolvedValue({ services: [{ id: 'ocr', name: '文字识别', engine: 'Paddle', ownership: 'renderer-worker', state: 'running', detail: '', actions: [] }], tasks: [] })
|
||||
const onClose = vi.fn()
|
||||
render(<LocalInferencePage onClose={onClose} />)
|
||||
await screen.findByRole('heading', { name: '文字识别' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '释放模型' }))
|
||||
const confirmation = screen.getByRole('dialog', { name: '释放模型:文字识别' })
|
||||
expect(confirmation).toHaveClass('local-inference-confirm')
|
||||
expect(within(confirmation).getByText('当前没有已登记的活动任务。执行前将再次核对。')).toBeInTheDocument()
|
||||
const confirm = within(confirmation).getByRole('button', { name: '确认释放模型' })
|
||||
expect(confirm).toHaveClass('danger-button')
|
||||
expect(release).not.toHaveBeenCalled()
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() => expect(confirmation).not.toBeInTheDocument())
|
||||
expect(release).toHaveBeenCalledOnce()
|
||||
expect(api.act).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
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: '关闭本机推理' })
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(close).toHaveFocus()
|
||||
fireEvent.keyDown(close, { key: 'Tab' })
|
||||
expect(close).toHaveFocus()
|
||||
expect(api.getSnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { InferenceAction, InferenceService, InferenceTask, LocalInferenceSnapshot } from '../../shared/local-inference-contracts'
|
||||
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> = {
|
||||
idle: '未加载 / 按需运行', running: '运行中', starting: '启动中', stopping: '停止中',
|
||||
stopped: '已停止', error: '异常', unavailable: '不可用', unknown: '未知',
|
||||
cancelling: '等待取消确认', completed: '已完成', cancelled: '已取消', failed: '失败'
|
||||
}
|
||||
const actionLabels = { start: '启动服务', stop: '停止服务', restart: '重启服务', load: '加载模型', release: '释放模型' }
|
||||
type Control = { service: InferenceService; action: InferenceAction['action'] | 'load' | 'release'; tasks: InferenceTask[] }
|
||||
|
||||
function ImpactConfirmation({ control, busy, onClose, onConfirm }: {
|
||||
control: Control; busy: boolean; onClose: () => void; onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
const cancel = useRef<HTMLButtonElement>(null)
|
||||
useLayoutEffect(() => activateModalFocus(() => cancel.current, () => document.querySelector<HTMLElement>('.local-inference-modal')), [])
|
||||
return createPortal(<div className="custom-task-dialog"><section className="custom-task-dialog__surface local-inference-confirm" role="dialog" aria-modal="true" aria-labelledby="inference-confirm-title" aria-describedby="inference-confirm-description" tabIndex={-1} onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') { event.preventDefault(); if (!busy) onClose() }
|
||||
}}><div className="custom-task-dialog__content">
|
||||
<h2 id="inference-confirm-title">{actionLabels[control.action]}:{control.service.name}</h2>
|
||||
<p id="inference-confirm-description">此操作影响整个执行服务。被中断的任务不会在服务重新启动后自动重放。</p>
|
||||
{control.tasks.length ? <ul>{control.tasks.map((task) => <li key={task.id}>{task.source} · {new Date(task.startedAt).toLocaleTimeString()} · {stateLabels[task.state]}</li>)}</ul> : <p>当前没有已登记的活动任务。执行前将再次核对。</p>}
|
||||
</div><div className="custom-task-dialog__actions">
|
||||
<button ref={cancel} type="button" className="secondary-button" disabled={busy} onClick={onClose}>保留运行状态</button>
|
||||
<button type="button" className="danger-button" disabled={busy} onClick={onConfirm}>{busy ? '操作中…' : `确认${actionLabels[control.action]}`}</button>
|
||||
</div>
|
||||
</section></div>, document.body)
|
||||
}
|
||||
|
||||
export default function LocalInferencePage({ onClose, enabled = true, restoreFocus }: {
|
||||
onClose: () => void; enabled?: boolean; restoreFocus?: () => HTMLElement | null
|
||||
}): React.JSX.Element {
|
||||
const close = useRef<HTMLButtonElement>(null)
|
||||
const restoreFocusRef = useRef(restoreFocus)
|
||||
useLayoutEffect(() => activateModalFocus(() => close.current, () => restoreFocusRef.current?.() ?? null), [])
|
||||
const [snapshot, setSnapshot] = useState<LocalInferenceSnapshot>()
|
||||
const [error, setError] = useState('')
|
||||
const [operationError, setOperationError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [control, setControl] = useState<Control>()
|
||||
const [reload, setReload] = useState(0)
|
||||
const operation = useRef(false)
|
||||
const [ocr, setOcr] = useState(getOcrInferenceState)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let inFlight = false
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (inFlight || !enabled || document.hidden || document.getElementById('local-inference-title')?.closest('[hidden], [inert]')) return
|
||||
inFlight = true
|
||||
setRefreshing(true)
|
||||
try {
|
||||
if (!window.goodbuddy.localInference) throw new Error('本机推理管理接口不可用,请重启应用后重试')
|
||||
const next = await window.goodbuddy.localInference.getSnapshot()
|
||||
if (alive) { setSnapshot(next); setOcr(getOcrInferenceState()); setError('') }
|
||||
} catch (reason) {
|
||||
if (alive) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
inFlight = false
|
||||
if (alive) setRefreshing(false)
|
||||
}
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(() => void refresh(), 5000)
|
||||
const onVisible = (): void => { void refresh() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => { alive = false; window.clearInterval(timer); document.removeEventListener('visibilitychange', onVisible) }
|
||||
}, [reload, enabled])
|
||||
|
||||
const services = useMemo(() => snapshot?.services.map((service) => service.id === 'ocr' ? {
|
||||
...service,
|
||||
state: ocr.state === 'idle' && (service.state === 'unavailable' || service.state === 'error') ? service.state : ocr.state,
|
||||
model: ocr.model ?? service.model, error: ocr.error ?? service.error
|
||||
} : service), [snapshot, ocr])
|
||||
const activeTasks = useMemo(() => snapshot?.tasks.filter((task) => task.finishedAt === undefined) ?? [], [snapshot])
|
||||
|
||||
const execute = async (selected: Control): Promise<void> => {
|
||||
if (operation.current) return
|
||||
operation.current = true
|
||||
setBusy(true)
|
||||
setOperationError('')
|
||||
try {
|
||||
if (selected.action === 'load') await loadOcrInference()
|
||||
else if (selected.action === 'release') releaseOcrInference()
|
||||
else await window.goodbuddy.localInference.act({ serviceId: selected.service.id, action: selected.action, confirmedTaskIds: selected.tasks.map((task) => task.id) })
|
||||
setControl(undefined)
|
||||
} catch (reason) {
|
||||
setOperationError(reason instanceof Error ? reason.message : String(reason))
|
||||
setControl(undefined)
|
||||
} finally {
|
||||
operation.current = false
|
||||
setBusy(false)
|
||||
setOcr(getOcrInferenceState())
|
||||
setReload((value) => value + 1)
|
||||
}
|
||||
}
|
||||
const selectAction = (service: InferenceService, action: Control['action']): void => {
|
||||
if (operation.current) return
|
||||
const selected = { service, action, tasks: activeTasks.filter((task) => task.serviceId === service.id) }
|
||||
setOperationError('')
|
||||
if (action === 'start' || action === 'load') void execute(selected)
|
||||
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) => {
|
||||
if (event.target instanceof Element && event.target.closest('[aria-modal="true"]') !== event.currentTarget) return
|
||||
trapTabFocus(event, event.currentTarget)
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!operation.current && !control) onClose()
|
||||
}
|
||||
}}>
|
||||
<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>} />
|
||||
</div>
|
||||
<div className="local-inference-modal__body">
|
||||
{!enabled && <div className="local-inference-actions"><strong>应用已关闭</strong><ApplicationSettingsLink id="local-inference" /></div>}
|
||||
<div className="local-inference-workspace" hidden={!enabled} inert={!enabled}>
|
||||
<div className="local-inference-toolbar">
|
||||
<p className="local-inference-note">每 5 秒刷新 · CPU 100% = 一个逻辑核心</p>
|
||||
<div className="local-inference-actions"><button className="secondary-button" type="button" disabled={busy} onClick={() => void window.goodbuddy.localInference.openSettings().catch((reason: unknown) => setOperationError(String(reason)))}>模型与连接设置</button><button className="secondary-button" type="button" disabled={refreshing || busy} onClick={() => setReload((value) => value + 1)}>刷新状态</button></div>
|
||||
</div>
|
||||
{error && <div className="local-inference-feedback" role="alert"><p>{snapshot ? '状态刷新失败;以下为上次成功读取的状态,操作已禁用。' : '无法读取本机推理状态。'} {error}</p><button type="button" className="secondary-button" onClick={() => setReload((value) => value + 1)}>重试</button></div>}
|
||||
{!snapshot && !error && <p role="status">正在读取服务状态…</p>}
|
||||
{operationError && !control && <div className="local-inference-feedback" role="alert">{operationError}<button type="button" className="secondary-button" onClick={() => setOperationError('')}>关闭提示</button></div>}
|
||||
{snapshot && <section aria-label="本机推理服务" className="local-inference-content">
|
||||
{services?.length === 0 && <p role="status">暂无本机推理服务</p>}
|
||||
{services?.map((service) => <article className="local-inference-row" key={service.id}>
|
||||
<div><div className="local-inference-row__heading"><h2>{service.name}</h2><span className="local-inference-state">{service.id === 'asr' && service.state === 'unknown' ? '按需运行(状态未知)' : stateLabels[service.state]}</span></div>
|
||||
<p className="local-inference-metadata">{service.engine}{service.model ? ` · ${service.model}` : ''}</p>
|
||||
<p className="local-inference-metadata">
|
||||
{service.resources?.scope === 'service-process'
|
||||
? `服务进程 PID ${service.resources.pid} · CPU ${service.resources.cpuPercent === undefined ? service.resources.cpuUnavailableReason : `${service.resources.cpuPercent.toFixed(1)}%`} · 工作集 ${service.resources.workingSetBytes === undefined ? service.resources.memoryUnavailableReason : `${(service.resources.workingSetBytes / 1024 / 1024).toFixed(1)} MiB`}`
|
||||
: `CPU / 内存:${service.resources?.reason ?? '暂不可用'}`}
|
||||
</p>
|
||||
{service.error && <p className="local-inference-error">{service.error}</p>}
|
||||
</div>
|
||||
<div className="local-inference-actions">
|
||||
{service.actions.map((action) => <button type="button" className="secondary-button" key={action} disabled={busy || refreshing || Boolean(error)} onClick={() => selectAction(service, action)}>{actionLabels[action]}</button>)}
|
||||
{service.id === 'ocr' && <button type="button" className="secondary-button" disabled={busy || refreshing || Boolean(error) || ocr.busy || service.state === 'unavailable'} onClick={() => selectAction(service, ocr.loaded ? 'release' : 'load')}>{ocr.loaded ? '释放模型' : '加载模型'}</button>}
|
||||
</div>
|
||||
</article>)}
|
||||
</section>}
|
||||
{control && <ImpactConfirmation control={control} busy={busy} onClose={() => { if (!operation.current) { setControl(undefined); setOperationError('') } }} onConfirm={() => void execute(control)} />}
|
||||
</div></div></section></div>, document.body)
|
||||
}
|
||||
@@ -15,10 +15,8 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
MagicNoteCommentMode,
|
||||
ModelDownloadSource
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
|
||||
import type {
|
||||
AgentPackageDownloadProgress,
|
||||
AgentPackageInventory
|
||||
@@ -34,8 +32,7 @@ import {
|
||||
} from '../../shared/shortcut'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import {
|
||||
PageTabs,
|
||||
SegmentedControl
|
||||
PageTabs
|
||||
} from './WorkspacePrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
@@ -47,10 +44,6 @@ type PlatformFeaturesSettingsSectionProps = {
|
||||
onConversationHtmlRenderingEnabledChange: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onMagicNotesEnabledChange: (enabled: boolean) => void
|
||||
onMagicNotesShowIncompleteTodoCountChange: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onRemoteProjectsEnabledChange: (enabled: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
@@ -62,7 +55,6 @@ type PlatformFeaturesSettingsSectionProps = {
|
||||
type PlatformFeaturesTab =
|
||||
| 'general'
|
||||
| 'remote-projects'
|
||||
| 'magic-notes'
|
||||
|
||||
const shortcutErrorTranslationKeys: Record<
|
||||
GlobalShortcutUpdateErrorCode,
|
||||
@@ -78,8 +70,6 @@ const shortcutErrorTranslationKeys: Record<
|
||||
|
||||
export function PlatformFeaturesSettingsSection({
|
||||
onConversationHtmlRenderingEnabledChange,
|
||||
onMagicNotesEnabledChange,
|
||||
onMagicNotesShowIncompleteTodoCountChange,
|
||||
onRemoteProjectsEnabledChange,
|
||||
onNotify,
|
||||
onDirtyChange,
|
||||
@@ -472,26 +462,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const changeMagicNotes = async (enabled: boolean): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
magicNotesEnabled: enabled
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onMagicNotesEnabledChange(nextSettings.magicNotesEnabled)
|
||||
} catch {
|
||||
setError(t('platformFeatures.errors.saveMagicNotesFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeConversationHtmlRendering = async (
|
||||
enabled: boolean
|
||||
): Promise<void> => {
|
||||
@@ -546,72 +516,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const changeCommentMode = async (
|
||||
magicNoteCommentMode: MagicNoteCommentMode
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
setSettings(
|
||||
await updates.updateSettings({ magicNoteCommentMode })
|
||||
)
|
||||
} catch {
|
||||
setError(t('platformFeatures.errors.saveCommentModeFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeIncompleteTodoCount = async (
|
||||
magicNotesShowIncompleteTodoCount: boolean
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
magicNotesShowIncompleteTodoCount
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onMagicNotesShowIncompleteTodoCountChange(
|
||||
nextSettings.magicNotesShowIncompleteTodoCount
|
||||
)
|
||||
} catch {
|
||||
setError(
|
||||
t('platformFeatures.errors.saveIncompleteTodoCountFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const changeCommentFormat = async (
|
||||
magicNoteCommentFormat: MagicNoteCommentFormat
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
setSettings(
|
||||
await updates.updateSettings({ magicNoteCommentFormat })
|
||||
)
|
||||
} catch {
|
||||
setError(t('platformFeatures.errors.saveCommentFormatFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCategoryHeader
|
||||
@@ -633,10 +537,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
{
|
||||
id: 'remote-projects',
|
||||
label: t('platformFeatures.remoteProjects.title')
|
||||
},
|
||||
{
|
||||
id: 'magic-notes',
|
||||
label: t('platformFeatures.tabs.magicNotes')
|
||||
}
|
||||
]}
|
||||
value={activeSection}
|
||||
@@ -1146,141 +1046,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-magic-notes"
|
||||
className="settings-section"
|
||||
hidden={activeSection !== 'magic-notes'}
|
||||
id="platform-features-panel-magic-notes"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>{t('platformFeatures.magicNotes.title')}</strong>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.description')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={settings.magicNotesEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeMagicNotes(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>{t('platformFeatures.magicNotes.showEntry')}</span>
|
||||
</label>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
checked={
|
||||
settings.magicNotesShowIncompleteTodoCount
|
||||
}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeIncompleteTodoCount(
|
||||
event.target.checked
|
||||
)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
'platformFeatures.magicNotes.showIncompleteTodoCount'
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<p className="settings-notice">
|
||||
{t(
|
||||
'platformFeatures.magicNotes.showIncompleteTodoCountHelp'
|
||||
)}
|
||||
</p>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentMode')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentModeAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentMode(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'immediate',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.immediate'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-auto',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveAuto'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'after-save-manual',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.modes.afterSaveManual'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentMode}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentModeHelp')}
|
||||
</small>
|
||||
</div>
|
||||
<div className="platform-feature-option">
|
||||
<span>
|
||||
{t('platformFeatures.magicNotes.commentFormat')}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
ariaLabel={t(
|
||||
'platformFeatures.magicNotes.commentFormatAria'
|
||||
)}
|
||||
disabled={saving}
|
||||
onChange={(value) => void changeCommentFormat(value)}
|
||||
options={[
|
||||
{
|
||||
value: 'combined',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.combined'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'narrative',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.narrative'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'structured',
|
||||
label: t(
|
||||
'platformFeatures.magicNotes.formats.structured'
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={settings.magicNoteCommentFormat}
|
||||
/>
|
||||
<small>
|
||||
{t('platformFeatures.magicNotes.commentFormatHelp')}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,12 +151,16 @@ function sidebarElement({
|
||||
workspaceProjectId,
|
||||
restoreFocusRef,
|
||||
activeConversationId,
|
||||
conversationStats,
|
||||
taskDurations,
|
||||
selectedTaskId,
|
||||
browserStates,
|
||||
onBackBrowser,
|
||||
onNavigateBrowser,
|
||||
onReloadBrowser,
|
||||
onStopLoadingBrowser,
|
||||
onOpenTask = vi.fn(),
|
||||
onCreateCustomTask = vi.fn(),
|
||||
onRespondApproval = vi.fn(),
|
||||
onTabChange = vi.fn()
|
||||
}: {
|
||||
@@ -185,12 +189,16 @@ function sidebarElement({
|
||||
workspaceProjectId?: string
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
activeConversationId?: string
|
||||
conversationStats?: React.ComponentProps<typeof RightAssistantSidebar>['conversationStats']
|
||||
taskDurations?: React.ComponentProps<typeof RightAssistantSidebar>['taskDurations']
|
||||
selectedTaskId?: string
|
||||
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
|
||||
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
|
||||
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
|
||||
onOpenTask?: (task: AssistantTask) => void
|
||||
onCreateCustomTask?: () => void
|
||||
onRespondApproval?: React.ComponentProps<typeof RightAssistantSidebar>['onRespondApproval']
|
||||
onTabChange?: (tab: AssistantSidebarTab) => void
|
||||
} = {}): React.JSX.Element {
|
||||
@@ -200,6 +208,9 @@ function sidebarElement({
|
||||
<RightAssistantSidebar
|
||||
approvals={approvals}
|
||||
activeConversationId={activeConversationId}
|
||||
conversationStats={conversationStats}
|
||||
taskDurations={taskDurations}
|
||||
selectedTaskId={selectedTaskId}
|
||||
artifacts={artifacts}
|
||||
browserStates={browserStates}
|
||||
schedules={[]}
|
||||
@@ -207,7 +218,7 @@ function sidebarElement({
|
||||
conversationTitles={new Map()}
|
||||
currentProject={activeProject ?? undefined}
|
||||
projectNames={new Map()}
|
||||
onCreateCustomTask={vi.fn()}
|
||||
onCreateCustomTask={onCreateCustomTask}
|
||||
onBackBrowser={onBackBrowser}
|
||||
onImportArtifacts={vi.fn(async () => undefined)}
|
||||
onListWorkspaceDirectory={onListWorkspaceDirectory}
|
||||
@@ -336,6 +347,87 @@ describe('RightAssistantSidebar tab titles', () => {
|
||||
})
|
||||
|
||||
describe('RightAssistantSidebar resizing', () => {
|
||||
it('updates conversation statistics without narrowing project tasks and rejects stale statistics', () => {
|
||||
const tasks: AssistantTask[] = ['a', 'b'].map((id) => ({
|
||||
id, conversationId: id, projectId: currentProject.id, title: `Project task ${id}`,
|
||||
instructions: '', origin: 'schedule', status: id === 'a' ? 'running' : 'completed',
|
||||
createdAt: '2026-09-01T00:00:00Z', startedAt: '2026-09-01T00:00:00Z',
|
||||
completedAt: id === 'b' ? '2026-09-17T00:00:00Z' : undefined
|
||||
}))
|
||||
const props = {
|
||||
tasks, selectedTaskId: 'a', activeConversationId: 'a',
|
||||
conversationStats: { conversationId: 'a', title: 'Conversation A', messageCount: 24, replyDurationMs: 3_661_000, incomplete: true },
|
||||
taskDurations: new Map([['a', { durationMs: 125_000, incomplete: true }]])
|
||||
}
|
||||
const view = render(sidebarElement(props))
|
||||
const stats = within(screen.getByRole('region', { name: '当前会话统计' }))
|
||||
expect(stats.getByText('01:01:01')).toBeVisible()
|
||||
expect(stats.getByText('24')).toBeVisible()
|
||||
expect(stats.getByText('部分回复缺少计时记录,时长仅包含已知记录。')).toBeVisible()
|
||||
expect(screen.getByText('任务累计用时: 00:02:05 (部分记录)')).toBeVisible()
|
||||
expect(screen.getByText('Project task a').closest('button')).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByText('Project task b').closest('article')).not.toHaveTextContent('任务累计用时')
|
||||
|
||||
view.rerender(sidebarElement({ ...props, activeConversationId: 'b' }))
|
||||
expect(stats.queryByText('Conversation A')).not.toBeInTheDocument()
|
||||
expect(stats.getAllByText('暂无统计')).toHaveLength(2)
|
||||
for (const task of tasks) expect(screen.getByText(task.title)).toBeVisible()
|
||||
view.rerender(sidebarElement({ ...props, activeConversationId: 'b', conversationStats: {
|
||||
conversationId: 'b', title: 'Conversation B', messageCount: 0, replyDurationMs: 0, incomplete: false
|
||||
} }))
|
||||
expect(stats.getByText('00:00:00')).toBeVisible()
|
||||
expect(stats.getByText('0')).toBeVisible()
|
||||
expect(stats.queryByText('部分回复缺少计时记录,时长仅包含已知记录。')).not.toBeInTheDocument()
|
||||
view.rerender(sidebarElement({ ...props, activeProject: { ...currentProject, id: 'other' } }))
|
||||
for (const task of tasks) expect(screen.queryByText(task.title)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps an applied filter visible when collapsed and clears it without opening the filter panel', () => {
|
||||
renderSidebar({ tasks: [{
|
||||
id: 'completed', projectId: currentProject.id, title: 'Completed task', instructions: '',
|
||||
origin: 'schedule', status: 'completed', createdAt: '2026-09-17T00:00:00Z'
|
||||
}] })
|
||||
const toggle = screen.getByRole('button', { name: '筛选任务' })
|
||||
expect(screen.getByText('Completed task')).toBeVisible()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(toggle).toHaveAttribute('data-active', 'true')
|
||||
expect(screen.queryByRole('group', { name: '筛选任务' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('筛选:进行中 · 0 项')).toBeVisible()
|
||||
expect(screen.getByText('当前筛选条件下没有任务。')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||
expect(toggle).toHaveAttribute('data-active', 'false')
|
||||
expect(screen.getByText('Completed task')).toBeVisible()
|
||||
})
|
||||
|
||||
it('scrolls the focused task filter into view when keyboard navigation reaches either end', () => {
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
|
||||
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
|
||||
const first = filters.getByRole('button', { name: '全部' })
|
||||
const last = filters.getByRole('button', { name: '已结束' })
|
||||
const scrollFirst = vi.fn()
|
||||
const scrollLast = vi.fn()
|
||||
Object.defineProperty(first, 'scrollIntoView', { value: scrollFirst })
|
||||
Object.defineProperty(last, 'scrollIntoView', { value: scrollLast })
|
||||
act(() => first.focus())
|
||||
scrollFirst.mockClear()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'End' })
|
||||
expect(last).toHaveFocus()
|
||||
expect(last).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(scrollLast).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
|
||||
expect(scrollFirst).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(last, { key: 'Home' })
|
||||
expect(first).toHaveFocus()
|
||||
expect(first).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(scrollFirst).toHaveBeenCalledExactlyOnceWith({ block: 'nearest', inline: 'nearest' })
|
||||
expect(scrollLast).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resizes with pointer capture and preserves compact pane minima', () => {
|
||||
const sidebar = renderSidebar()
|
||||
const separator = screen.getByRole('separator', {
|
||||
@@ -553,12 +645,13 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
})
|
||||
|
||||
it('keeps the product Task index in the task center', () => {
|
||||
renderSidebar({ tab: 'tasks' })
|
||||
const onCreateCustomTask = vi.fn()
|
||||
renderSidebar({ tab: 'tasks', onCreateCustomTask })
|
||||
|
||||
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(i18n.t('sidebar.tasks.noApprovals', { ns: 'workspace' }))).not.toBeInTheDocument()
|
||||
const taskIndexHeading = screen.getByRole('heading', {
|
||||
name: '任务索引'
|
||||
name: '项目任务 0'
|
||||
})
|
||||
const newTaskButton = screen.getByRole('button', {
|
||||
name: '新建任务'
|
||||
@@ -566,9 +659,12 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(taskIndexHeading.parentElement).toContainElement(
|
||||
newTaskButton
|
||||
)
|
||||
fireEvent.click(newTaskButton)
|
||||
expect(onCreateCustomTask).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
screen.getByRole('group', { name: '筛选任务' })
|
||||
).toBeInTheDocument()
|
||||
screen.queryByRole('group', { name: '筛选任务' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '筛选任务' })).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -600,9 +696,11 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
approvals: [approval], onOpenTask, onRespondApproval
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
|
||||
const filters = within(screen.getByRole('group', { name: '筛选任务' }))
|
||||
expect(filters.getAllByRole('button').map((button) => button.textContent))
|
||||
.toEqual(['进行中', '暂停', '已结束'])
|
||||
.toEqual(['全部', '进行中', '暂停', '已结束'])
|
||||
fireEvent.click(filters.getByRole('button', { name: '进行中' }))
|
||||
expect(filters.getByRole('button', { name: '进行中' })).toHaveAttribute('aria-pressed', 'true')
|
||||
for (const task of tasks.slice(0, 6)) {
|
||||
const row = screen.getByText(task.title).closest('article')!
|
||||
@@ -644,6 +742,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
}
|
||||
const onRespondApproval = vi.fn()
|
||||
renderSidebar({ tasks: [task], approvals: [approval], onRespondApproval })
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选任务' }))
|
||||
const row = screen.getByText(task.title).closest('article')!
|
||||
expect(within(row).getByLabelText('等待审批: Confirm write')).toBeVisible()
|
||||
expect(screen.queryByRole('heading', { name: '等待审批' })).not.toBeInTheDocument()
|
||||
@@ -685,9 +784,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(within(row).queryByText(`Approval ${index}`)).not.toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByText('Approval 4')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '所有项目' }))
|
||||
expect(screen.getByText('Approval 4')).toBeVisible()
|
||||
expect(within(row).queryByText('Approval 4')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('Approval 0')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -842,18 +939,11 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
|
||||
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
|
||||
expect(screen.queryByText('全局审批')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
|
||||
'button',
|
||||
{ name: '所有项目' }
|
||||
)
|
||||
)
|
||||
expect(screen.getByText('全局审批')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前项目审批')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('等待审批: 2')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scopes tasks to the current or all projects, labels unbound tasks and persists the choice', () => {
|
||||
it('keeps task scope fixed to the current project', () => {
|
||||
const createTask = (
|
||||
id: string,
|
||||
title: string,
|
||||
@@ -882,44 +972,31 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
|
||||
expect(screen.queryByText('全局任务')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('其他项目任务')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByRole('group', { name: '任务范围' })).getAllByRole('button'))
|
||||
.toHaveLength(2)
|
||||
expect(screen.queryByRole('group', { name: '任务范围' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '全局' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('group', { name: '任务范围' })).getByRole(
|
||||
'button',
|
||||
{ name: '所有项目' }
|
||||
)
|
||||
)
|
||||
expect(screen.getByText('当前项目任务')).toBeInTheDocument()
|
||||
expect(screen.getByText('全局任务')).toBeInTheDocument()
|
||||
expect(screen.getByText('其他项目任务')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('未绑定项目')).toHaveLength(1)
|
||||
expect(screen.getByText('项目:00000000-0000-4000-8000-000000000302')).toBeInTheDocument()
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!))
|
||||
.toEqual(expect.objectContaining({ taskScope: 'all-projects' }))
|
||||
.toEqual(expect.objectContaining({ taskScope: 'current-project' }))
|
||||
})
|
||||
|
||||
it('restores the legacy global scope as all projects and saves the updated preference', () => {
|
||||
it.each(['global', 'all-projects'])('ignores the persisted %s scope and saves current project scope', (taskScope) => {
|
||||
localStorage.setItem('goodbuddy.workbar-layout.v1', JSON.stringify({
|
||||
instances: DEFAULT_WORKBAR_INSTANCES,
|
||||
activeInstanceId: DEFAULT_WORKBAR_INSTANCES[0]!.id,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.3,
|
||||
taskScope: 'global'
|
||||
taskScope
|
||||
}))
|
||||
renderSidebar({ activeProject: null, tasks: [{
|
||||
id: 'legacy-unbound-task', title: '历史任务', instructions: '历史任务',
|
||||
origin: 'schedule', status: 'running', createdAt: '2026-09-09T00:00:00.000Z'
|
||||
}] })
|
||||
|
||||
expect(screen.getByRole('button', { name: '所有项目' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('历史任务')).toBeInTheDocument()
|
||||
expect(screen.getByText('未绑定项目')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '所有项目' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('历史任务')).not.toBeInTheDocument()
|
||||
expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!).taskScope)
|
||||
.toBe('all-projects')
|
||||
.toBe('current-project')
|
||||
})
|
||||
|
||||
it('shows a clear current-project empty state when no project is active', () => {
|
||||
@@ -927,7 +1004,7 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'没有活动项目。请选择其他任务范围或先打开一个项目。'
|
||||
'没有活动项目。请先打开一个项目以查看和创建任务。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('等待审批')).not.toBeInTheDocument()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Filter,
|
||||
ClockFading,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
@@ -60,7 +61,6 @@ import {
|
||||
normalizeWorkbarLayoutPreferences,
|
||||
workbarLayoutPreferencesSchema,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarTaskScope,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from './WorkbarShell'
|
||||
import type { TerminalAdapter } from './TerminalPanel'
|
||||
import './terminal-panel.css'
|
||||
import './task-center.css'
|
||||
|
||||
const TerminalPanel = lazy(async () => {
|
||||
const module = await import('./TerminalPanel')
|
||||
@@ -102,7 +103,22 @@ export type PendingSidebarApproval = {
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
type RightAssistantSidebarProps = {
|
||||
export type SidebarConversationStats = {
|
||||
// Must match activeConversationId; late results for other conversations are ignored.
|
||||
conversationId: string
|
||||
title?: string
|
||||
messageCount: number
|
||||
// Authoritative cumulative reply time, excluding idle gaps. Never a lifecycle delta.
|
||||
replyDurationMs: number
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
export type SidebarTaskDuration = {
|
||||
durationMs: number
|
||||
incomplete?: boolean
|
||||
}
|
||||
|
||||
export type RightAssistantSidebarProps = {
|
||||
open: boolean
|
||||
tab: AssistantSidebarTab
|
||||
approvals: PendingSidebarApproval[]
|
||||
@@ -115,6 +131,8 @@ type RightAssistantSidebarProps = {
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
activeConversationId?: string
|
||||
conversationStats?: SidebarConversationStats
|
||||
taskDurations?: ReadonlyMap<string, SidebarTaskDuration>
|
||||
browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
@@ -210,8 +228,7 @@ function persistWorkbarLayout(
|
||||
instances: readonly WorkbarTabInstance[],
|
||||
activeInstanceId: string | null,
|
||||
expanded: boolean,
|
||||
widthRatio: number,
|
||||
taskScope: WorkbarTaskScope
|
||||
widthRatio: number
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
@@ -223,7 +240,7 @@ function persistWorkbarLayout(
|
||||
expanded,
|
||||
dock: 'right',
|
||||
widthRatio,
|
||||
taskScope
|
||||
taskScope: 'current-project'
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -664,6 +681,8 @@ export function RightAssistantSidebar({
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
activeConversationId,
|
||||
conversationStats,
|
||||
taskDurations,
|
||||
browserStates = {},
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
@@ -688,6 +707,13 @@ export function RightAssistantSidebar({
|
||||
}: RightAssistantSidebarProps): React.JSX.Element {
|
||||
const { i18n, t } = useTranslation('workspace')
|
||||
const locale = i18n.resolvedLanguage || 'zh-CN'
|
||||
const currentStats = activeConversationId && conversationStats?.conversationId === activeConversationId
|
||||
? conversationStats
|
||||
: undefined
|
||||
const formatDuration = (durationMs: number): string => {
|
||||
const seconds = Math.floor(Math.max(0, durationMs) / 1000)
|
||||
return `${Math.floor(seconds / 3600).toString().padStart(2, '0')}:${Math.floor(seconds / 60 % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
const sidebarTimeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(locale, {
|
||||
@@ -857,13 +883,9 @@ export function RightAssistantSidebar({
|
||||
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
|
||||
useState('')
|
||||
const [taskFilter, setTaskFilter] = useState<
|
||||
'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
const [taskScope, setTaskScope] = useState<Exclude<WorkbarTaskScope, 'global'>>(
|
||||
initialLayout?.taskScope === 'global'
|
||||
? 'all-projects'
|
||||
: initialLayout?.taskScope ?? 'current-project'
|
||||
)
|
||||
'all' | 'active' | 'paused' | 'finished'
|
||||
>('all')
|
||||
const [taskFiltersExpanded, setTaskFiltersExpanded] = useState(false)
|
||||
const [actionErrorState, setActionErrorState] = useState({
|
||||
scope: { instanceId: activeWorkbarInstanceId, open },
|
||||
message: ''
|
||||
@@ -950,12 +972,9 @@ export function RightAssistantSidebar({
|
||||
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum
|
||||
const taskMatchesScope = useCallback(
|
||||
(projectId?: string): boolean => {
|
||||
if (taskScope === 'all-projects') {
|
||||
return true
|
||||
}
|
||||
return Boolean(currentProject && projectId === currentProject.id)
|
||||
},
|
||||
[currentProject, taskScope]
|
||||
[currentProject]
|
||||
)
|
||||
const scopedApprovals = useMemo(
|
||||
() => approvals.filter((approval) => taskMatchesScope(approval.projectId)),
|
||||
@@ -988,6 +1007,7 @@ export function RightAssistantSidebar({
|
||||
const filteredTasks = useMemo(
|
||||
() =>
|
||||
topLevelTasks.filter((task) => {
|
||||
if (taskFilter === 'all') return true
|
||||
if (approvalsByTask.has(task.id)) return taskFilter === 'active'
|
||||
if (taskFilter === 'active') {
|
||||
return (
|
||||
@@ -1067,14 +1087,12 @@ export function RightAssistantSidebar({
|
||||
workbarInstances,
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio,
|
||||
taskScope
|
||||
sidebarRatio
|
||||
)
|
||||
}, [
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio,
|
||||
taskScope,
|
||||
workbarInstances
|
||||
])
|
||||
|
||||
@@ -1914,59 +1932,61 @@ export function RightAssistantSidebar({
|
||||
</Suspense>
|
||||
) : null}
|
||||
{instance.appId === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
<div className="task-center__filters">
|
||||
<SegmentedControl
|
||||
ariaLabel={t('sidebar.tasks.scope.ariaLabel')}
|
||||
onChange={setTaskScope}
|
||||
options={[
|
||||
{
|
||||
value: 'current-project',
|
||||
label: t('sidebar.tasks.scope.currentProject')
|
||||
},
|
||||
{
|
||||
value: 'all-projects',
|
||||
label: t('sidebar.tasks.scope.allProjects')
|
||||
}
|
||||
]}
|
||||
value={taskScope}
|
||||
/>
|
||||
</div>
|
||||
{taskScope === 'current-project' && !currentProject ? (
|
||||
<section className="assistant-sidebar__section task-center">
|
||||
<p className="task-center__scope">{currentProject
|
||||
? t('sidebar.tasks.projectScope', { project: currentProject.name })
|
||||
: t('sidebar.tasks.scope.currentProject')}</p>
|
||||
<section className="task-center__stats" aria-label={t('sidebar.tasks.stats.label')}>
|
||||
<span className="task-center__eyebrow">{t('sidebar.tasks.stats.currentConversation')}</span>
|
||||
<h3>{activeConversationId
|
||||
? currentStats?.title ?? conversationTitles.get(activeConversationId) ?? t('sidebar.tasks.stats.currentConversation')
|
||||
: t('sidebar.tasks.stats.noConversation')}</h3>
|
||||
<dl className="task-center__stat-grid">
|
||||
<div><dt>{t('sidebar.tasks.stats.replyDuration')}</dt><dd>{currentStats ? formatDuration(currentStats.replyDurationMs) : t('sidebar.tasks.stats.unavailable')}</dd></div>
|
||||
<div><dt>{t('sidebar.tasks.stats.messages')}</dt><dd>{currentStats ? currentStats.messageCount.toLocaleString(locale) : t('sidebar.tasks.stats.unavailable')}</dd></div>
|
||||
</dl>
|
||||
{currentStats?.incomplete && <p className="task-center__incomplete">{t('sidebar.tasks.stats.incomplete')}</p>}
|
||||
<details><summary>{t('sidebar.tasks.stats.timeHelp')}</summary><p>{t('sidebar.tasks.stats.timeDescription')}</p></details>
|
||||
</section>
|
||||
{!currentProject ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('sidebar.tasks.scope.noCurrentProject')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{unassociatedApprovals.length > 0 && <>
|
||||
<h3>
|
||||
<ShieldAlert size={15} />
|
||||
{t('sidebar.tasks.approvalsTitle')}
|
||||
</h3>
|
||||
{unassociatedApprovals.map((approval) => (
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
|
||||
))}
|
||||
</>}
|
||||
|
||||
<div className="task-center__index-heading">
|
||||
<h3>
|
||||
<ClockFading size={15} />
|
||||
{t('sidebar.tasks.taskIndexTitle')}
|
||||
{t('sidebar.tasks.taskIndexTitle')}{' '}
|
||||
<span className="task-center__count">{topLevelTasks.length}</span>
|
||||
</h3>
|
||||
<button
|
||||
className="secondary-button task-center__create"
|
||||
aria-label={t('taskStrip.create')}
|
||||
title={t('taskStrip.create')}
|
||||
onClick={onCreateCustomTask}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={13} />
|
||||
{t('taskStrip.create')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button task-center__filter-toggle"
|
||||
aria-label={t('sidebar.tasks.filters.ariaLabel')}
|
||||
title={t('sidebar.tasks.filters.ariaLabel')}
|
||||
aria-expanded={taskFiltersExpanded}
|
||||
aria-controls={`task-filters-${instance.id}`}
|
||||
data-active={taskFilter !== 'all'}
|
||||
onClick={() => setTaskFiltersExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
><Filter aria-hidden="true" size={14} /></button>
|
||||
</div>
|
||||
<div className="task-center__filters">
|
||||
<div className="task-center__filters" id={`task-filters-${instance.id}`} hidden={!taskFiltersExpanded}
|
||||
onFocusCapture={(event) => event.target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })}>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
|
||||
onChange={setTaskFilter}
|
||||
options={[
|
||||
{ value: 'all', label: t('sidebar.tasks.filters.all') },
|
||||
{
|
||||
value: 'active',
|
||||
label: t('sidebar.tasks.filters.active')
|
||||
@@ -1983,6 +2003,16 @@ export function RightAssistantSidebar({
|
||||
value={taskFilter}
|
||||
/>
|
||||
</div>
|
||||
{taskFilter !== 'all' && <div className="task-center__filter-summary">
|
||||
<span>{t('sidebar.tasks.filters.summary', { filter: t(`sidebar.tasks.filters.${taskFilter}`), count: filteredTasks.length })}</span>
|
||||
<button type="button" onClick={() => setTaskFilter('all')}>{t('sidebar.tasks.filters.clear')}</button>
|
||||
</div>}
|
||||
{unassociatedApprovals.length > 0 && <>
|
||||
<h3><ShieldAlert size={15} />{t('sidebar.tasks.approvalsTitle')}</h3>
|
||||
{unassociatedApprovals.map((approval) => (
|
||||
<SidebarApproval key={approval.approvalId} approval={approval} onRespondApproval={onRespondApproval} />
|
||||
))}
|
||||
</>}
|
||||
{filteredTasks.length === 0 ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{topLevelTasks.length === 0
|
||||
@@ -1998,6 +2028,7 @@ export function RightAssistantSidebar({
|
||||
const projectName = task.projectId
|
||||
? projectNames.get(task.projectId)
|
||||
: undefined
|
||||
const duration = taskDurations?.get(task.id)
|
||||
return (
|
||||
<article
|
||||
className={
|
||||
@@ -2010,6 +2041,7 @@ export function RightAssistantSidebar({
|
||||
<button
|
||||
className="task-center__item-main"
|
||||
onClick={() => onOpenTask(task)}
|
||||
aria-current={selectedTaskId === task.id ? 'true' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
@@ -2045,6 +2077,10 @@ export function RightAssistantSidebar({
|
||||
: t('task.mode.unavailable')}
|
||||
</span>
|
||||
<span>{t(`task.status.${task.status}`)}</span>
|
||||
{duration && <span title={t('sidebar.tasks.stats.taskDuration')}>
|
||||
{t('sidebar.tasks.stats.taskDuration')}: {formatDuration(duration.durationMs)}
|
||||
{duration.incomplete && ` (${t('sidebar.tasks.stats.partial')})`}
|
||||
</span>}
|
||||
</div>
|
||||
<p>
|
||||
{task.error ??
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from '../../shared/capability-contracts'
|
||||
import {
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
@@ -547,6 +548,7 @@ let applicationSettings: ApplicationSettings = {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -838,6 +840,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: false,
|
||||
@@ -998,7 +1001,8 @@ describe('SettingsPanel runtime files', () => {
|
||||
updateSettings: updateApplicationSettings,
|
||||
check: vi.fn(),
|
||||
openReleasePage: vi.fn(),
|
||||
onResult: vi.fn(() => () => {})
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
},
|
||||
localToolEnvironment: {
|
||||
getSnapshot: getLocalToolEnvironmentSnapshot,
|
||||
@@ -1500,71 +1504,19 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles Magic Notes navigation settings', async () => {
|
||||
const onMagicNotesEnabledChange = vi.fn()
|
||||
const onMagicNotesShowIncompleteTodoCountChange = vi.fn()
|
||||
it('omits Magic Notes from global platform settings without changing stored settings', async () => {
|
||||
const storedSettings = structuredClone(applicationSettings)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
onMagicNotesShowIncompleteTodoCountChange={
|
||||
onMagicNotesShowIncompleteTodoCountChange
|
||||
}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
const toggle = await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
expect(toggle).not.toBeChecked()
|
||||
expect(screen.getByText(/默认关闭/)).toBeInTheDocument()
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
)
|
||||
expect(onMagicNotesEnabledChange).toHaveBeenCalledWith(true)
|
||||
const countToggle = screen.getByRole('switch', {
|
||||
name: '显示未完成待办数量'
|
||||
})
|
||||
expect(countToggle).toBeChecked()
|
||||
fireEvent.click(countToggle)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNotesShowIncompleteTodoCount: false
|
||||
})
|
||||
)
|
||||
expect(
|
||||
onMagicNotesShowIncompleteTodoCountChange
|
||||
).toHaveBeenCalledWith(false)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存后自动' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNoteCommentMode: 'after-save-auto'
|
||||
})
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '长评 + 要点' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '要点' }))
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNoteCommentFormat: 'structured'
|
||||
})
|
||||
)
|
||||
expect(await screen.findByRole('tab', { name: '通用设置' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.queryByRole('tab', { name: '魔法笔记', hidden: true })).not.toBeInTheDocument()
|
||||
expect(document.getElementById('platform-features-panel-magic-notes')).toBeNull()
|
||||
expect(screen.queryByRole('switch', { name: '显示魔法笔记入口' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
|
||||
expect(updateApplicationSettings).not.toHaveBeenCalled()
|
||||
expect(applicationSettings).toEqual(storedSettings)
|
||||
})
|
||||
|
||||
it('switches the global model download source from General settings', async () => {
|
||||
@@ -1609,9 +1561,9 @@ describe('SettingsPanel runtime files', () => {
|
||||
dedupeKey: 'model-download-source'
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '魔法笔记' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '远程项目(技术预览)' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '显示魔法笔记入口' })
|
||||
screen.getByRole('tabpanel', { name: '远程项目(技术预览)' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1712,24 +1664,17 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
|
||||
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
|
||||
function Harness(): React.JSX.Element {
|
||||
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
|
||||
return (
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
onMagicNotesEnabledChange={setMagicNotesEnabled}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<Harness />
|
||||
const panel = (magicNotesEnabled: boolean): React.JSX.Element => (
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const { rerender } = render(panel(false))
|
||||
|
||||
openCapabilitySettingsTab('MCP')
|
||||
const noteServerToggle = await screen.findByRole('button', {
|
||||
@@ -1739,22 +1684,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
'capability-card--disabled'
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
await screen.findByRole('tab', { name: '魔法笔记' })
|
||||
)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('switch', {
|
||||
name: '显示魔法笔记入口'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
magicNotesEnabled: true
|
||||
})
|
||||
)
|
||||
|
||||
openCapabilitySettingsTab('MCP')
|
||||
rerender(panel(true))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
|
||||
@@ -242,11 +242,7 @@ type SettingsPanelProps = {
|
||||
onConversationHtmlRenderingEnabledChange?: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
|
||||
onMagicNotesShowIncompleteTodoCountChange?: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onShortcutSettingsChanged?: (
|
||||
snapshot: GlobalShortcutSettingsSnapshot
|
||||
) => void
|
||||
@@ -655,9 +651,7 @@ export function SettingsPanel({
|
||||
magicNotesEnabled = false,
|
||||
remoteProjectsEnabled = false,
|
||||
onConversationHtmlRenderingEnabledChange = () => {},
|
||||
onMagicNotesEnabledChange = () => {},
|
||||
onRemoteProjectsEnabledChange = () => {},
|
||||
onMagicNotesShowIncompleteTodoCountChange = () => {},
|
||||
onShortcutSettingsChanged = () => {},
|
||||
onLeaveRequestReady = () => {}
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
@@ -2301,10 +2295,6 @@ export function SettingsPanel({
|
||||
onConversationHtmlRenderingEnabledChange={
|
||||
onConversationHtmlRenderingEnabledChange
|
||||
}
|
||||
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
|
||||
onMagicNotesShowIncompleteTodoCountChange={
|
||||
onMagicNotesShowIncompleteTodoCountChange
|
||||
}
|
||||
onRemoteProjectsEnabledChange={
|
||||
onRemoteProjectsEnabledChange
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AssistantSchedule } from '../../shared/assistant-contracts'
|
||||
import { TaskScheduleActions } from './TaskScheduleActions'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const triggeredAt = '2026-09-17T12:00:00.000Z'
|
||||
const schedule: AssistantSchedule = {
|
||||
id: '00000000-0000-4000-8000-000000000801',
|
||||
taskId: '00000000-0000-4000-8000-000000000802',
|
||||
conversationId: '00000000-0000-4000-8000-000000000803',
|
||||
title: 'Immediate task', prompt: 'Run once', workMode: 'ask',
|
||||
recurrence: 'once', enabled: false,
|
||||
nextRunAt: triggeredAt, lastRunAt: triggeredAt,
|
||||
createdAt: triggeredAt, updatedAt: triggeredAt
|
||||
}
|
||||
|
||||
describe('TaskScheduleActions', () => {
|
||||
it('offers an explicit new run, not automatic resume, for a cancelled immediate task', async () => {
|
||||
const onRunSchedule = vi.fn().mockResolvedValue(undefined)
|
||||
const onSetScheduleEnabled = vi.fn()
|
||||
render(<TaskScheduleActions schedule={schedule} taskStatus="cancelled" taskTitle={schedule.title}
|
||||
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={onRunSchedule}
|
||||
onSetScheduleEnabled={onSetScheduleEnabled} />)
|
||||
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
|
||||
await waitFor(() => expect(onRunSchedule).toHaveBeenCalledExactlyOnceWith(schedule.id))
|
||||
expect(onSetScheduleEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not allow resume or another run while the immediate occurrence is queued', () => {
|
||||
render(<TaskScheduleActions schedule={schedule} taskStatus="queued" taskTitle={schedule.title}
|
||||
onError={vi.fn()} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()}
|
||||
onSetScheduleEnabled={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: '恢复' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '立即运行' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it.each(['once', 'daily', 'weekly'] as const)('retains resume for a paused untriggered %s schedule', async (recurrence) => {
|
||||
const onSetScheduleEnabled = vi.fn().mockResolvedValue(undefined)
|
||||
render(<TaskScheduleActions schedule={{ ...schedule, recurrence, lastRunAt: undefined }}
|
||||
taskStatus="paused" taskTitle={schedule.title} onError={vi.fn()}
|
||||
onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={onSetScheduleEnabled} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复' }))
|
||||
await waitFor(() => expect(onSetScheduleEnabled).toHaveBeenCalledExactlyOnceWith(schedule.id, true))
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
defaultLocalToolEnvironmentSettings,
|
||||
defaultApplicationNavigation,
|
||||
type ApplicationSettings
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
@@ -25,6 +26,7 @@ describe('UpdateSettingsSection', () => {
|
||||
updateSource: 'github',
|
||||
modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings,
|
||||
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
|
||||
conversationHtmlRenderingEnabled: true,
|
||||
remoteProjectsEnabled: false,
|
||||
magicNotesEnabled: true,
|
||||
@@ -88,7 +90,8 @@ describe('UpdateSettingsSection', () => {
|
||||
updateSettings,
|
||||
check,
|
||||
openReleasePage: vi.fn(),
|
||||
onResult: vi.fn(() => () => {})
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
@@ -193,7 +196,8 @@ describe('UpdateSettingsSection', () => {
|
||||
)
|
||||
}),
|
||||
openReleasePage: vi.fn(),
|
||||
onResult: vi.fn(() => () => {})
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
@@ -237,7 +241,8 @@ describe('UpdateSettingsSection', () => {
|
||||
openReleasePage: vi.fn(async () => {
|
||||
throw new Error('Shell refused the release URL')
|
||||
}),
|
||||
onResult: vi.fn(() => () => {})
|
||||
onResult: vi.fn(() => () => {}),
|
||||
onSettingsChanged: vi.fn(() => () => {})
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
@@ -23,6 +23,10 @@ const stylesheet = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
|
||||
'utf8'
|
||||
)
|
||||
const taskCenterStylesheet = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'task-center.css'),
|
||||
'utf8'
|
||||
)
|
||||
const rendererEntry = readFileSync(
|
||||
join(process.cwd(), 'src', 'renderer', 'src', 'main.tsx'),
|
||||
'utf8'
|
||||
@@ -346,11 +350,14 @@ describe('WorkspacePrimitives', () => {
|
||||
expect(stylesheet).not.toMatch(
|
||||
/@media \(max-width: 720px\)\s*\{[\s\S]*?\.conversation-task-strip__header\s*\{[^}]*flex-direction:\s*column;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.task-center__filters \.segmented-control\s*\{[^}]*width:\s*100%;[^}]*overflow:\s*hidden;/u
|
||||
})
|
||||
|
||||
it('keeps narrow task filters scrollable without shrinking their labels', () => {
|
||||
expect(taskCenterStylesheet).toMatch(
|
||||
/\.task-center \.task-center__filters \.segmented-control\s*\{[^}]*overflow-x:\s*auto;/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.task-center__filters \.segmented-control__option\s*\{[^}]*min-width:\s*0;[^}]*flex:\s*1;/u
|
||||
expect(taskCenterStylesheet).toMatch(
|
||||
/\.task-center \.task-center__filters \.segmented-control__option\s*\{[^}]*flex:\s*1 0 auto;[^}]*min-width:\s*max-content;/u
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.anchored-menu {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-dialog) - 1);
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-body);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.anchored-menu [role="menuitem"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anchored-menu [role="menuitem"] svg { flex-shrink: 0; color: var(--text-secondary); }
|
||||
.anchored-menu [role="menuitem"]:hover { background: var(--surface-subtle); }
|
||||
.anchored-menu [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.anchored-menu [role="separator"] { border-top: 1px solid var(--border-subtle); margin: var(--space-2) 0; }
|
||||
.anchored-menu p { margin: var(--space-2); color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,199 @@
|
||||
.settings-backdrop .settings-panel.application-center {
|
||||
width: min(960px, calc(100vw - 2 * var(--space-4)));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 2 * var(--space-4));
|
||||
}
|
||||
|
||||
.settings-backdrop .settings-panel.application-center--detail {
|
||||
width: min(640px, calc(100vw - 2 * var(--space-4)));
|
||||
}
|
||||
.application-center .settings-panel__header {
|
||||
gap: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.application-center .settings-panel__header > .icon-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.application-center .settings-panel__header .page-header {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
.application-center .settings-panel__header .page-header__content {
|
||||
padding-right: 0;
|
||||
}
|
||||
.application-center .settings-panel__header .page-header .page-header__actions {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.application-center .settings-panel__header h1 {
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.application-center__body {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-0);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
.application-center__search {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.application-center__search input {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
min-height: 36px;
|
||||
min-width: 0;
|
||||
}
|
||||
.application-center__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
padding: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.application-center__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.application-center__summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.application-center__summary > svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.application-center__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
.application-center__title strong {
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
.application-center__summary p {
|
||||
color: var(--text-secondary);
|
||||
margin: var(--space-1) 0 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.application-center__summary small {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.application-center__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: auto;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.application-settings {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.application-settings__group {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.application-settings__group + .application-settings__group {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
.application-settings .toggle-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.application-settings .toggle-row span {
|
||||
font-weight: 500;
|
||||
}
|
||||
.application-settings .settings-notice {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.application-settings .platform-feature-option {
|
||||
grid-template-columns: minmax(100px, 1fr) auto;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.application-settings .segmented-control {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.application-center .settings-panel__header {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.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 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.application-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.application-page-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.application-page-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.application-page-content[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.sidebar-footer--applications {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.sidebar-footer--applications > .nav-item {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.custom-task-dialog__form {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
@@ -14,8 +14,9 @@ export function trapTabFocus(
|
||||
if (event.key !== 'Tab' || !container) {
|
||||
return
|
||||
}
|
||||
const focusable =
|
||||
const focusable = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector)
|
||||
).filter((element) => !element.closest('[hidden], [inert], [aria-hidden="true"]'))
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault()
|
||||
container.focus()
|
||||
@@ -36,7 +37,8 @@ export function trapTabFocus(
|
||||
}
|
||||
|
||||
export function activateModalFocus(
|
||||
initialFocus: () => HTMLElement | null
|
||||
initialFocus: () => HTMLElement | null,
|
||||
fallbackFocus?: () => HTMLElement | null
|
||||
): () => void {
|
||||
const restoreFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
@@ -86,6 +88,10 @@ export function activateModalFocus(
|
||||
for (const { element, wasInert } of isolatedElements) {
|
||||
element.inert = wasInert
|
||||
}
|
||||
restoreFocus?.focus()
|
||||
const canRestore = restoreFocus?.isConnected &&
|
||||
restoreFocus !== document.body &&
|
||||
!restoreFocus.closest('[hidden], [inert], [aria-hidden="true"], :disabled')
|
||||
const target = canRestore ? restoreFocus : fallbackFocus?.()
|
||||
target?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,29 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('document OCR worker idle release', () => {
|
||||
it('reports a failed model load and recovers on the next real request', async () => {
|
||||
const api = await setup()
|
||||
const { getOcrInferenceState } = await import('./document-ocr-bridge')
|
||||
api.getOcrAssets.mockRejectedValueOnce(new Error('model unavailable'))
|
||||
await api.start()
|
||||
expect(getOcrInferenceState()).toMatchObject({ state: 'error', loaded: false, busy: false, error: 'model unavailable' })
|
||||
const next = await api.start()
|
||||
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, error: undefined })
|
||||
await api.finish(next)
|
||||
})
|
||||
it('reports the actual worker and refuses manual release while busy', async () => {
|
||||
const api = await setup()
|
||||
const { getOcrInferenceState, releaseOcrInference } = await import('./document-ocr-bridge')
|
||||
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
|
||||
const input = await api.start()
|
||||
expect(getOcrInferenceState()).toMatchObject({ state: 'running', loaded: true, busy: true })
|
||||
expect(() => releaseOcrInference()).toThrow('活动或排队任务')
|
||||
expect(TestWorker.instances[0]!.terminate).not.toHaveBeenCalled()
|
||||
await api.finish(input)
|
||||
releaseOcrInference()
|
||||
expect(TestWorker.instances[0]!.terminate).toHaveBeenCalledOnce()
|
||||
expect(getOcrInferenceState()).toMatchObject({ state: 'idle', loaded: false })
|
||||
})
|
||||
it('releases after 60 idle seconds and reloads the model for the next request', async () => {
|
||||
const api = await setup()
|
||||
const input = await api.start()
|
||||
|
||||
@@ -18,6 +18,10 @@ type PendingWorkerRequest = {
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
let workerLoaded = false
|
||||
let workerError: string | undefined
|
||||
let managementLoading = false
|
||||
let startupTimer: number | undefined
|
||||
let workerModelId: string | undefined
|
||||
let workerReady: Promise<void> | undefined
|
||||
let resolveWorkerReady: (() => void) | undefined
|
||||
@@ -55,9 +59,12 @@ function safeError(error: unknown): string {
|
||||
|
||||
function terminateWorker(error: Error): void {
|
||||
clearIdleTimer()
|
||||
window.clearTimeout(startupTimer)
|
||||
startupTimer = undefined
|
||||
worker?.terminate()
|
||||
rejectWorkerReady?.(error)
|
||||
worker = undefined
|
||||
workerLoaded = false
|
||||
workerModelId = undefined
|
||||
workerReady = undefined
|
||||
resolveWorkerReady = undefined
|
||||
@@ -97,15 +104,27 @@ async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
new URL('./document-ocr-worker.ts', import.meta.url),
|
||||
{ type: 'module', name: 'goodbuddy-document-ocr' }
|
||||
)
|
||||
workerModelId = modelId
|
||||
workerReady = new Promise<void>((resolve, reject) => {
|
||||
resolveWorkerReady = resolve
|
||||
rejectWorkerReady = reject
|
||||
})
|
||||
const ready = workerReady
|
||||
void ready.catch(() => undefined)
|
||||
// A worker that never becomes ready must not leave manual loading locked forever.
|
||||
startupTimer = window.setTimeout(() => {
|
||||
workerError = '本地 OCR 模型加载超时,请重试'
|
||||
terminateWorker(new Error(workerError))
|
||||
}, 120_000)
|
||||
worker.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<WorkerOutput>) => {
|
||||
const output = event.data
|
||||
if (output.type === 'ready') {
|
||||
window.clearTimeout(startupTimer)
|
||||
startupTimer = undefined
|
||||
workerLoaded = true
|
||||
workerError = undefined
|
||||
resolveWorkerReady?.()
|
||||
return
|
||||
}
|
||||
@@ -141,6 +160,7 @@ async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
}
|
||||
)
|
||||
worker.addEventListener('error', (event) => {
|
||||
workerError = event.message || '本地 OCR Worker 异常'
|
||||
terminateWorker(
|
||||
new Error(event.message || '本地 OCR Worker 异常')
|
||||
)
|
||||
@@ -159,8 +179,9 @@ async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
assets.dictionary
|
||||
]
|
||||
)
|
||||
await workerReady
|
||||
await ready
|
||||
} catch (error) {
|
||||
workerError = safeError(error)
|
||||
terminateWorker(
|
||||
error instanceof Error ? error : new Error('本地 OCR 初始化失败')
|
||||
)
|
||||
@@ -169,6 +190,40 @@ async function ensureWorker(modelId: string): Promise<Worker> {
|
||||
return worker
|
||||
}
|
||||
|
||||
export function getOcrInferenceState(): {
|
||||
state: 'idle' | 'running' | 'starting' | 'error'
|
||||
model?: string
|
||||
busy: boolean
|
||||
loaded: boolean
|
||||
error?: string
|
||||
} {
|
||||
return {
|
||||
state: worker ? workerLoaded ? 'running' : 'starting' : workerError ? 'error' : 'idle',
|
||||
model: workerModelId, loaded: workerLoaded,
|
||||
busy: managementLoading || queuedRequestCount > 0 || Boolean(activeRequestId) || pending.size > 0,
|
||||
error: workerError
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadOcrInference(): Promise<void> {
|
||||
if (getOcrInferenceState().busy) throw new Error('OCR 正在处理任务,请等待完成后再加载')
|
||||
managementLoading = true
|
||||
try {
|
||||
const snapshot = await window.goodbuddy.documentParsing?.getSnapshot()
|
||||
if (!snapshot) throw new Error('文档解析服务不可用')
|
||||
if (!snapshot.status.localOcr.available) throw new Error(snapshot.status.localOcr.detail)
|
||||
await ensureWorker(snapshot.settings.localOcrModelId)
|
||||
} finally {
|
||||
managementLoading = false
|
||||
scheduleIdleRelease()
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseOcrInference(): void {
|
||||
if (getOcrInferenceState().busy) throw new Error('OCR 仍有活动或排队任务,无法释放共享 Worker;请等待任务结束')
|
||||
terminateWorker(new Error('用户已释放 OCR 模型'))
|
||||
}
|
||||
|
||||
async function recognize(
|
||||
request: DocumentOcrRequest
|
||||
): Promise<DocumentOcrResult> {
|
||||
|
||||
@@ -2,6 +2,22 @@ import type { TranslationShape } from '../../resource-types'
|
||||
import type { app as chineseApp } from '../zh-CN/app'
|
||||
|
||||
export const app = {
|
||||
applications: {
|
||||
fixed: 'Fixed entry', 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',
|
||||
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',
|
||||
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
|
||||
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||
descriptions: {
|
||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||
heartbeat: 'Review heartbeat suggestions and schedules.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
@@ -39,6 +55,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
navigation: {
|
||||
settings: 'Settings',
|
||||
label: 'Main navigation',
|
||||
chat: 'Chat',
|
||||
magicNotes: 'Magic Notes',
|
||||
@@ -137,11 +154,14 @@ export const app = {
|
||||
eyebrow: 'CUSTOM TASK',
|
||||
title: 'New custom task',
|
||||
description:
|
||||
'Send task instructions to an ordinary conversation on schedule. Replies stay in the conversation just like manually sent messages.',
|
||||
'Send task content to a conversation now or on schedule. Replies stay in the linked conversation.',
|
||||
nameHelp: 'Optional. Leave blank to generate a name from the task content.',
|
||||
timing: { now: 'Run immediately', scheduled: 'Schedule' },
|
||||
close: 'Close new custom task',
|
||||
fields: {
|
||||
name: 'Task name',
|
||||
instructions: 'Task instructions',
|
||||
instructions: 'Task content',
|
||||
timing: 'Execution time',
|
||||
destination: 'Conversation',
|
||||
mode: 'Work mode',
|
||||
recurrence: 'Frequency',
|
||||
@@ -151,11 +171,11 @@ export const app = {
|
||||
current: 'Current conversation',
|
||||
new: 'New conversation',
|
||||
currentHelp:
|
||||
'Use this conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
|
||||
'Use the selected conversation\'s history, Runtime, work mode, and enabled capabilities when the message is sent.',
|
||||
newHelp:
|
||||
'Create an independent ordinary conversation with project defaults and the task name as its title.',
|
||||
currentUnavailable:
|
||||
'The current conversation cannot host this task. Choose a new conversation.'
|
||||
'The current conversation cannot host this task. Choose another conversation in this project or a new conversation.'
|
||||
},
|
||||
mode: {
|
||||
execute: 'Execute',
|
||||
@@ -183,7 +203,7 @@ export const app = {
|
||||
},
|
||||
errors: {
|
||||
title: 'Enter a task name.',
|
||||
instructions: 'Enter task instructions.',
|
||||
instructions: 'Enter task content.',
|
||||
destination: 'Choose an available conversation.',
|
||||
time: 'Choose a valid first run time.',
|
||||
futureTime: 'The first run must be in the future.',
|
||||
|
||||
@@ -557,8 +557,7 @@ export const settingsSections = {
|
||||
label: 'Platform feature options',
|
||||
tabs: {
|
||||
ariaLabel: 'Platform feature settings',
|
||||
general: 'General',
|
||||
magicNotes: 'Magic Notes'
|
||||
general: 'General'
|
||||
},
|
||||
conversationHtml: {
|
||||
title: 'Conversation HTML rendering',
|
||||
@@ -680,7 +679,7 @@ export const settingsSections = {
|
||||
magicNotes: {
|
||||
title: 'Magic Notes',
|
||||
description:
|
||||
'Off by default. Enable it to capture notes and to-dos and analyze content with AI.',
|
||||
'Capture notes and to-dos and analyze content with AI. Manage preferences in Application settings.',
|
||||
showEntry: 'Show Magic Notes',
|
||||
showIncompleteTodoCount: 'Show incomplete to-do count',
|
||||
showIncompleteTodoCountHelp:
|
||||
|
||||
@@ -322,7 +322,20 @@ export const workspace = {
|
||||
noApprovals: 'There are no operations awaiting approval.',
|
||||
deny: 'Deny',
|
||||
allowOnce: 'Allow once',
|
||||
taskIndexTitle: 'Task index',
|
||||
taskIndexTitle: 'Project tasks',
|
||||
stats: {
|
||||
label: 'Current conversation statistics',
|
||||
currentConversation: 'Current conversation',
|
||||
noConversation: 'No conversation selected',
|
||||
replyDuration: 'Cumulative Agent reply time',
|
||||
messages: 'Messages',
|
||||
unavailable: 'Not available',
|
||||
incomplete: 'Some replies have no timing records. Only known durations are included.',
|
||||
partial: 'Partial records',
|
||||
timeHelp: 'About timing',
|
||||
timeDescription: 'Adds the actual duration of each Agent reply, excluding idle time between replies. Missing durations are not estimated from conversation or task start and end times.',
|
||||
taskDuration: 'Cumulative task time'
|
||||
},
|
||||
empty: 'Explicitly created tasks will appear here.',
|
||||
noFilterResults: 'No tasks match this filter.',
|
||||
conversationUnavailable: 'Conversation unavailable',
|
||||
@@ -336,9 +349,12 @@ export const workspace = {
|
||||
currentProject: 'Current project',
|
||||
allProjects: 'All projects',
|
||||
noCurrentProject:
|
||||
'No project is active. Choose another task scope or open a project.'
|
||||
'No project is active. Open a project to view and create tasks.'
|
||||
},
|
||||
filters: {
|
||||
all: 'All',
|
||||
summary: 'Filter: {{filter}} · {{count}} tasks',
|
||||
clear: 'Clear filter',
|
||||
ariaLabel: 'Filter tasks',
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
export const app = {
|
||||
applications: {
|
||||
fixed: '固定入口', optional: '可选',
|
||||
manage: '管理应用',
|
||||
title: '应用中心', description: '打开内置应用,管理可选应用的启用、常驻和顺序。知识库和智能心跳固定显示在左侧菜单。',
|
||||
localInference: '本机推理', settings: '应用设置', enable: '启用应用', pin: '常驻左侧菜单',
|
||||
enableHelp: '常驻只控制左侧捷径;关闭应用保留数据和常驻偏好,不停止已有请求或后台服务。',
|
||||
disabledPage: '应用已关闭', close: '关闭应用中心', retry: '重新读取', back: '返回应用中心',
|
||||
search: '搜索内置应用', noResults: '未找到相关应用', clear: '清除搜索', builtin: '内置',
|
||||
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
|
||||
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||
descriptions: {
|
||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||
heartbeat: '查看智能心跳建议与计划。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
@@ -36,6 +52,7 @@ export const app = {
|
||||
}
|
||||
},
|
||||
navigation: {
|
||||
settings: '设置',
|
||||
label: '主导航',
|
||||
chat: '对话',
|
||||
magicNotes: '魔法笔记',
|
||||
@@ -129,11 +146,14 @@ export const app = {
|
||||
customTask: {
|
||||
eyebrow: '定制任务',
|
||||
title: '新建定制任务',
|
||||
description: '按计划向普通会话发送任务要求,回复和手动发送的消息一样保留在会话中。',
|
||||
description: '立即或按计划向会话发送任务内容,回复保留在关联会话中。',
|
||||
nameHelp: '可选,留空时从任务内容生成名称。',
|
||||
timing: { now: '立即执行', scheduled: '定时执行' },
|
||||
close: '关闭新建定制任务',
|
||||
fields: {
|
||||
name: '任务名称',
|
||||
instructions: '任务要求',
|
||||
instructions: '任务内容',
|
||||
timing: '执行时间',
|
||||
destination: '关联会话',
|
||||
mode: '执行模式',
|
||||
recurrence: '运行频率',
|
||||
@@ -142,9 +162,9 @@ export const app = {
|
||||
destination: {
|
||||
current: '当前会话',
|
||||
new: '新建会话',
|
||||
currentHelp: '发送时使用当前会话的历史、Runtime、工作模式和已启用能力。',
|
||||
currentHelp: '发送时使用所选会话的历史、Runtime、工作模式和已启用能力。',
|
||||
newHelp: '创建独立的普通会话,使用项目默认设置,以任务名称作为标题。',
|
||||
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
|
||||
currentUnavailable: '当前会话不能关联此任务,请选择本项目其他会话或新建会话。'
|
||||
},
|
||||
mode: {
|
||||
execute: 'Execute',
|
||||
@@ -170,7 +190,7 @@ export const app = {
|
||||
},
|
||||
errors: {
|
||||
title: '请输入任务名称。',
|
||||
instructions: '请输入任务要求。',
|
||||
instructions: '请输入任务内容。',
|
||||
destination: '请选择可用的关联会话。',
|
||||
time: '请选择有效的首次运行时间。',
|
||||
futureTime: '首次运行时间必须晚于当前时间。',
|
||||
|
||||
@@ -528,8 +528,7 @@ export const settingsSections = {
|
||||
label: '平台功能选项',
|
||||
tabs: {
|
||||
ariaLabel: '平台功能设置',
|
||||
general: '通用设置',
|
||||
magicNotes: '魔法笔记'
|
||||
general: '通用设置'
|
||||
},
|
||||
conversationHtml: {
|
||||
title: '会话 HTML 渲染',
|
||||
@@ -634,7 +633,7 @@ export const settingsSections = {
|
||||
},
|
||||
magicNotes: {
|
||||
title: '魔法笔记',
|
||||
description: '默认关闭;开启后可记录笔记与待办,并使用 AI 分析内容',
|
||||
description: '记录笔记与待办,使用 AI 分析内容;相关配置统一在应用设置中管理。',
|
||||
showEntry: '显示魔法笔记入口',
|
||||
showIncompleteTodoCount: '显示未完成待办数量',
|
||||
showIncompleteTodoCountHelp:
|
||||
|
||||
@@ -314,7 +314,20 @@ export const workspace = {
|
||||
noApprovals: '当前没有等待审批的操作。',
|
||||
deny: '拒绝',
|
||||
allowOnce: '仅此次允许',
|
||||
taskIndexTitle: '任务索引',
|
||||
taskIndexTitle: '项目任务',
|
||||
stats: {
|
||||
label: '当前会话统计',
|
||||
currentConversation: '当前会话',
|
||||
noConversation: '尚未选择会话',
|
||||
replyDuration: 'Agent 累计回复时长',
|
||||
messages: '消息数',
|
||||
unavailable: '暂无统计',
|
||||
incomplete: '部分回复缺少计时记录,时长仅包含已知记录。',
|
||||
partial: '部分记录',
|
||||
timeHelp: '时间说明',
|
||||
timeDescription: '累计每次 Agent 回复的实际用时,不包含回复之间的空闲时间。缺少计时记录的回复不按会话或任务的起止时间估算。',
|
||||
taskDuration: '任务累计用时'
|
||||
},
|
||||
empty: '明确创建的任务会显示在这里。',
|
||||
noFilterResults: '当前筛选条件下没有任务。',
|
||||
conversationUnavailable: '关联会话不可用',
|
||||
@@ -327,9 +340,12 @@ export const workspace = {
|
||||
ariaLabel: '任务范围',
|
||||
currentProject: '当前项目',
|
||||
allProjects: '所有项目',
|
||||
noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。'
|
||||
noCurrentProject: '没有活动项目。请先打开一个项目以查看和创建任务。'
|
||||
},
|
||||
filters: {
|
||||
all: '全部',
|
||||
summary: '筛选:{{filter}} · {{count}} 项',
|
||||
clear: '清除筛选',
|
||||
ariaLabel: '筛选任务',
|
||||
active: '进行中',
|
||||
paused: '暂停',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.local-inference-content { display: flex; flex-direction: column; font-size: var(--font-body); line-height: 1.6; }
|
||||
.local-inference-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--space-3) var(--space-4); padding-block: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
.local-inference-row:last-child { border-bottom: 0; }
|
||||
.local-inference-row > div:first-child { min-width: 0; overflow-wrap: anywhere; }
|
||||
.local-inference-row h2 { margin: 0; font-size: var(--font-section-title); }
|
||||
.local-inference-row__heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); }
|
||||
.local-inference-state { flex: 0 0 auto; font-size: var(--font-caption); color: var(--text-secondary); }
|
||||
.local-inference-row p { margin: var(--space-1) 0 0; color: var(--text-secondary); }
|
||||
.local-inference-row .local-inference-metadata { font-size: var(--font-caption); }
|
||||
.local-inference-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.local-inference-actions:empty { display: none; }
|
||||
.local-inference-row > .local-inference-actions { justify-content: flex-end; }
|
||||
.local-inference-row button { white-space: nowrap; }
|
||||
.local-inference-note { color: var(--text-secondary); margin: 0; font-size: var(--font-body); line-height: 1.6; }
|
||||
.local-inference-feedback { padding: var(--space-4); margin-block: var(--space-4); border: 1px solid var(--danger-border); border-radius: var(--radius-control); }
|
||||
.local-inference-row .local-inference-error, .local-inference-feedback { color: var(--danger); }
|
||||
.custom-task-dialog__surface.local-inference-modal { width: min(960px, 100%); height: auto; max-height: min(720px, calc(100dvh - 2 * var(--space-6))); grid-template-rows: auto minmax(0, 1fr); }
|
||||
.local-inference-modal .custom-task-dialog__header { align-items: center; padding: var(--space-3) var(--space-4); }
|
||||
.local-inference-modal .custom-task-dialog__header .page-header { width: 100%; margin: 0; padding: 0; border: 0; flex-direction: row; align-items: center; }
|
||||
.local-inference-modal .custom-task-dialog__header .page-header .page-header__actions { width: auto; flex-shrink: 0; justify-content: flex-end; }
|
||||
.local-inference-modal .custom-task-dialog__header h1 { font-size: var(--font-section-title); }
|
||||
.local-inference-modal .custom-task-dialog__header .icon-button { min-width: 40px; min-height: 40px; }
|
||||
.local-inference-modal__body { min-height: 0; overflow: auto; padding: var(--space-4); }
|
||||
.local-inference-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-4); }
|
||||
.local-inference-workspace[hidden] { display: none; }
|
||||
.custom-task-dialog__surface.local-inference-confirm { width: min(480px, 100%); grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.local-inference-confirm .custom-task-dialog__content { padding: var(--space-4); gap: var(--space-3); font-size: var(--font-body); line-height: 1.6; }
|
||||
.local-inference-confirm h2 { font-size: var(--font-section-title); }
|
||||
.local-inference-confirm h2, .local-inference-confirm p, .local-inference-confirm ul { margin: 0; }
|
||||
.local-inference-confirm ul { padding-left: var(--space-4); overflow-wrap: anywhere; }
|
||||
.local-inference-confirm .custom-task-dialog__actions { padding: var(--space-3) var(--space-4); gap: var(--space-2); justify-content: flex-end; flex-wrap: wrap; }
|
||||
.local-inference-confirm h2 { overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.local-inference-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.local-inference-row > .local-inference-actions { justify-content: flex-start; }
|
||||
.local-inference-row > button { justify-self: start; }
|
||||
.local-inference-toolbar > .local-inference-actions { width: 100%; }
|
||||
}
|
||||
@@ -3739,6 +3739,7 @@ textarea:focus-visible {
|
||||
* rather than adding a per-dialog copy.
|
||||
*/
|
||||
:is(
|
||||
.custom-task-dialog,
|
||||
.project-create-backdrop,
|
||||
.remote-directory-picker-backdrop,
|
||||
.html-preview-viewer-backdrop,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
.task-center .task-center__scope {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-center__stats {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.task-center__eyebrow,
|
||||
.task-center__stats dt,
|
||||
.task-center__stats details,
|
||||
.task-center__incomplete {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.task-center .task-center__stats h3 {
|
||||
display: block;
|
||||
margin: var(--space-2) 0 var(--space-4);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-center__stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
|
||||
.task-center__stat-grid dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-section-title);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-center__stats summary { cursor: pointer; }
|
||||
.task-center__stats details p { margin-bottom: 0; }
|
||||
|
||||
.task-center .task-center__index-heading {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.task-center .task-center__index-heading h3 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-center__count { color: var(--text-muted); }
|
||||
|
||||
.task-center .task-center__create,
|
||||
.task-center .task-center__filter-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-center .task-center__filter-toggle[data-active='true'],
|
||||
.task-center .task-center__filter-toggle[aria-expanded='true'] {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.task-center .task-center__filters { flex: 0 0 auto; }
|
||||
.task-center .task-center__filters .segmented-control { overflow-x: auto; }
|
||||
.task-center .task-center__filters .segmented-control__option {
|
||||
flex: 1 0 auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
.task-center .task-center__filters[hidden] { display: none; }
|
||||
|
||||
.task-center__filter-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.task-center__filter-summary button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
padding: var(--space-2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.task-center__filter-summary button:hover { background: var(--accent-subtle); }
|
||||
.task-center button:focus-visible,
|
||||
.task-center summary:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
|
||||
import { useExecutionStats } from './use-execution-stats'
|
||||
|
||||
function stats(durationMs: number): ExecutionStats {
|
||||
return {
|
||||
durationMs, requestCount: 1, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: 1_000, taskDurations: []
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: ExecutionStats) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<ExecutionStats>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const empty = { conversation: undefined, project: undefined }
|
||||
const initialProps = {
|
||||
conversationId: 'chat-a' as string | undefined,
|
||||
projectId: 'project-a' as string | undefined,
|
||||
revision: '1', enabled: true
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
return renderHook(({ conversationId, projectId, revision, enabled }) =>
|
||||
useExecutionStats(conversationId, projectId, revision, enabled), { initialProps })
|
||||
}
|
||||
|
||||
const getExecutionStats = vi.fn<(input: ExecutionStatsInput) => Promise<ExecutionStats>>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
getExecutionStats.mockReset().mockResolvedValue(stats(10))
|
||||
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useExecutionStats', () => {
|
||||
it.each(['project', 'conversation'] as const)(
|
||||
'clears the previous %s selection and ignores its late polling result', async (scope) => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
|
||||
const oldConversation = deferred()
|
||||
const oldProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
|
||||
const newConversation = deferred()
|
||||
const newProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(newConversation.promise).mockReturnValueOnce(newProject.promise)
|
||||
rerender({
|
||||
...initialProps, conversationId: 'chat-b',
|
||||
projectId: scope === 'project' ? 'project-b' : 'project-a'
|
||||
})
|
||||
expect(result.current).toEqual({
|
||||
conversation: undefined, project: scope === 'project' ? undefined : stats(10)
|
||||
})
|
||||
expect(getExecutionStats.mock.calls.slice(-2)).toEqual([
|
||||
[{ conversationId: 'chat-b' }],
|
||||
[{ projectId: scope === 'project' ? 'project-b' : 'project-a' }]
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
newConversation.resolve(stats(20))
|
||||
newProject.resolve(stats(30))
|
||||
})
|
||||
await act(async () => {
|
||||
oldConversation.resolve(stats(90))
|
||||
oldProject.resolve(stats(99))
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not resurrect cancelled results when switching back to the same IDs', async () => {
|
||||
const oldConversation = deferred()
|
||||
const oldProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
|
||||
const { result, rerender } = renderStats()
|
||||
rerender({ ...initialProps, conversationId: 'chat-b', projectId: 'project-b' })
|
||||
await act(async () => {})
|
||||
const currentConversation = deferred()
|
||||
const currentProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(currentConversation.promise).mockReturnValueOnce(currentProject.promise)
|
||||
rerender(initialProps)
|
||||
await act(async () => {
|
||||
oldConversation.resolve(stats(90))
|
||||
oldProject.resolve(stats(99))
|
||||
})
|
||||
expect(result.current).toEqual(empty)
|
||||
await act(async () => {
|
||||
currentConversation.resolve(stats(20))
|
||||
currentProject.resolve(stats(30))
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
})
|
||||
|
||||
it('does not overlap polling, focus or visibility refreshes while either query is pending', async () => {
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
const { result } = renderStats()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
conversation.resolve(stats(20))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toEqual(empty)
|
||||
await act(async () => { project.resolve(stats(30)) })
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
await act(async () => { vi.advanceTimersByTime(4_999) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('replaces the poller on revision changes and removes timers and listeners on unmount', async () => {
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
const { result, rerender, unmount } = renderStats()
|
||||
await act(async () => { vi.advanceTimersByTime(2_000) })
|
||||
rerender({ ...initialProps, revision: '2' })
|
||||
await act(async () => {})
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await act(async () => {
|
||||
conversation.resolve(stats(90))
|
||||
project.resolve(stats(99))
|
||||
vi.advanceTimersByTime(3_000)
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
await act(async () => { vi.advanceTimersByTime(2_000) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(6)
|
||||
|
||||
const lastConversation = deferred()
|
||||
const lastProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(lastConversation.promise).mockReturnValueOnce(lastProject.promise)
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
lastConversation.resolve(stats(50))
|
||||
lastProject.resolve(stats(60))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it.each(['conversation', 'project', 'both'] as const)(
|
||||
'clears stale statistics when %s queries fail and recovers on the next poll', async (failure) => {
|
||||
const { result } = renderStats()
|
||||
await act(async () => {})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
getExecutionStats.mockImplementation(async (input) => {
|
||||
if (failure === 'both' ||
|
||||
(failure === 'conversation' && 'conversationId' in input) ||
|
||||
(failure === 'project' && 'projectId' in input)) {
|
||||
throw new Error('stats unavailable')
|
||||
}
|
||||
return stats(20)
|
||||
})
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current).toEqual({
|
||||
conversation: failure === 'project' ? stats(20) : undefined,
|
||||
project: failure === 'conversation' ? stats(20) : undefined
|
||||
})
|
||||
getExecutionStats.mockResolvedValue(stats(30))
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current).toEqual({ conversation: stats(30), project: stats(30) })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not query while hidden and resumes when visible', async () => {
|
||||
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
|
||||
const { result } = renderStats()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual(empty)
|
||||
visibility.mockReturnValue('visible')
|
||||
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
visibility.mockReturnValue('hidden')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
visibility.mockReturnValue('visible')
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it.each(['disabled', 'no-project'] as const)('stops querying and hides results when %s', async (mode) => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
rerender({
|
||||
...initialProps, enabled: mode !== 'disabled',
|
||||
projectId: mode === 'no-project' ? undefined : initialProps.projectId
|
||||
})
|
||||
expect(result.current).toEqual(empty)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
conversation.resolve(stats(90))
|
||||
project.reject(new Error('late failure'))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
expect(result.current).toEqual(empty)
|
||||
rerender(initialProps)
|
||||
await act(async () => {})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(6)
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
})
|
||||
|
||||
it('queries only the project when no conversation is selected', async () => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
getExecutionStats.mockClear()
|
||||
rerender({ ...initialProps, conversationId: undefined })
|
||||
expect(result.current.conversation).toBeUndefined()
|
||||
await act(async () => {})
|
||||
expect(getExecutionStats.mock.calls).toEqual([[{ projectId: 'project-a' }]])
|
||||
expect(result.current).toEqual({ conversation: undefined, project: stats(10) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ExecutionStats } from '../../shared/assistant-contracts'
|
||||
|
||||
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
|
||||
export function useExecutionStats(
|
||||
conversationId: string | undefined,
|
||||
projectId: string | undefined,
|
||||
revision: string,
|
||||
enabled: boolean
|
||||
): { conversation?: ExecutionStats; project?: ExecutionStats } {
|
||||
const [snapshot, setSnapshot] = useState<{
|
||||
conversationId?: string; projectId?: string
|
||||
conversation?: ExecutionStats; project?: ExecutionStats
|
||||
}>({})
|
||||
useEffect(() => {
|
||||
if (!enabled || !projectId) return
|
||||
let cancelled = false
|
||||
let pending = false
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (pending || document.visibilityState === 'hidden') return
|
||||
pending = true
|
||||
const [conversation, project] = await Promise.allSettled([
|
||||
conversationId
|
||||
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
|
||||
: Promise.resolve(undefined),
|
||||
window.goodbuddy.tasks.getExecutionStats({ projectId })
|
||||
])
|
||||
pending = false
|
||||
if (!cancelled) setSnapshot({
|
||||
conversationId, projectId,
|
||||
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
|
||||
project: project.status === 'fulfilled' ? project.value : undefined
|
||||
})
|
||||
}
|
||||
void refresh()
|
||||
const interval = window.setInterval(() => { void refresh() }, 5_000)
|
||||
const onFocus = (): void => { void refresh() }
|
||||
window.addEventListener('focus', onFocus)
|
||||
document.addEventListener('visibilitychange', onFocus)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(interval)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
document.removeEventListener('visibilitychange', onFocus)
|
||||
}
|
||||
}, [conversationId, projectId, revision, enabled])
|
||||
return {
|
||||
conversation: enabled && snapshot.projectId === projectId && snapshot.conversationId === conversationId
|
||||
? snapshot.conversation : undefined,
|
||||
project: enabled && snapshot.projectId === projectId ? snapshot.project : undefined
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,22 @@ export type MagicNoteCommentMode = z.infer<typeof magicNoteCommentModeSchema>
|
||||
export const updateSourceSchema = z.enum(['github', 'mirror'])
|
||||
export type UpdateSource = z.infer<typeof updateSourceSchema>
|
||||
|
||||
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'] as const
|
||||
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 }
|
||||
}
|
||||
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'),
|
||||
pinned: z.object({
|
||||
'magic-notes': z.boolean(), 'local-inference': z.boolean()
|
||||
}).strict()
|
||||
}).strict()
|
||||
|
||||
const applicationPreferencesSchema = z
|
||||
.object({
|
||||
checkUpdatesOnStartup: z.boolean(),
|
||||
@@ -38,10 +54,12 @@ const applicationPreferencesSchema = z
|
||||
localToolEnvironment: localToolEnvironmentSettingsSchema,
|
||||
conversationHtmlRenderingEnabled: z.boolean(),
|
||||
remoteProjectsEnabled: z.boolean(),
|
||||
magicNotesEnabled: z.boolean(),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean(),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema,
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema
|
||||
applicationNavigation: applicationNavigationSchema.default(defaultApplicationNavigation),
|
||||
localInferenceEnabled: z.boolean().default(true),
|
||||
magicNotesEnabled: z.boolean().default(true),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean().default(true),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema.default('immediate'),
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema.default('combined')
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -53,6 +71,14 @@ export const applicationSettingsSchema = applicationPreferencesSchema
|
||||
|
||||
export const applicationSettingsUpdateSchema = applicationPreferencesSchema
|
||||
.partial()
|
||||
.extend({
|
||||
applicationNavigation: applicationNavigationSchema.optional(),
|
||||
localInferenceEnabled: z.boolean().optional(),
|
||||
magicNotesEnabled: z.boolean().optional(),
|
||||
magicNotesShowIncompleteTodoCount: z.boolean().optional(),
|
||||
magicNoteCommentMode: magicNoteCommentModeSchema.optional(),
|
||||
magicNoteCommentFormat: magicNoteCommentFormatSchema.optional()
|
||||
})
|
||||
.refine((input) => Object.keys(input).length > 0, {
|
||||
message: 'At least one application setting is required'
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
executionStatsInputSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
@@ -37,6 +38,15 @@ const untouchedProject: AssistantProject = {
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
it('requires exactly one execution statistics scope and rejects renderer timing inputs', () => {
|
||||
const id = untouchedProject.id
|
||||
expect(executionStatsInputSchema.parse({ conversationId: id })).toEqual({ conversationId: id })
|
||||
expect(executionStatsInputSchema.parse({ projectId: id })).toEqual({ projectId: id })
|
||||
for (const input of [{}, { conversationId: id, projectId: id }, { projectId: 'bad' }, { projectId: id, asOf: 100 }]) {
|
||||
expect(executionStatsInputSchema.safeParse(input).success).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
describe('agent question contracts', () => {
|
||||
it('returns recovered questions only in live snapshots, not saved headers', () => {
|
||||
const id = '00000000-0000-4000-8000-000000000901'
|
||||
|
||||
@@ -4,6 +4,24 @@ import { agentRuntimeSelectionSchema } from './runtime-selection-contracts'
|
||||
import { sshHostIdSchema } from './ssh-host-contracts'
|
||||
|
||||
export const assistantIdSchema = z.string().uuid()
|
||||
export const executionStatsInputSchema = z.union([
|
||||
z.object({ conversationId: assistantIdSchema }).strict(),
|
||||
z.object({ projectId: assistantIdSchema }).strict()
|
||||
])
|
||||
export type ExecutionStatsInput = z.infer<typeof executionStatsInputSchema>
|
||||
export interface ExecutionStats {
|
||||
/** Sum of evidenced top-level reply intervals, including tools and approvals. */
|
||||
durationMs: number
|
||||
requestCount: number
|
||||
/** Requests/replies with missing timing evidence; confirmed live intervals are complete as of asOf. */
|
||||
incompleteRequestCount: number
|
||||
/** Requests currently leased by Main; does not authorize renderer extrapolation. */
|
||||
activeRequestCount: number
|
||||
/** Epoch milliseconds at query time. */
|
||||
asOf: number
|
||||
/** Project card totals; empty for conversation queries. */
|
||||
taskDurations: Array<{ id: string; durationMs: number; incompleteRequestCount: number }>
|
||||
}
|
||||
export const interactiveWorkModes = ['ask', 'execute'] as const
|
||||
export const workModeSchema = z.enum(interactiveWorkModes)
|
||||
export const legacyWorkModeSchema = z.enum([
|
||||
@@ -809,13 +827,18 @@ export const scheduleCreateSchema = z
|
||||
prompt: z.string().trim().min(1).max(100_000),
|
||||
workMode: workModeSchema.default('execute'),
|
||||
recurrence: z.enum(['once', 'daily', 'weekly']),
|
||||
nextRunAt: z.string().datetime({ offset: true })
|
||||
nextRunAt: z.string().datetime({ offset: true }),
|
||||
runImmediately: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((input) => !input.runImmediately || input.recurrence === 'once', {
|
||||
message: '立即执行只支持单次任务',
|
||||
path: ['recurrence']
|
||||
})
|
||||
|
||||
export type ScheduleCreateInput = z.input<typeof scheduleCreateSchema>
|
||||
|
||||
export type AssistantSchedule = z.output<typeof scheduleCreateSchema> & {
|
||||
export type AssistantSchedule = Omit<z.output<typeof scheduleCreateSchema>, 'runImmediately'> & {
|
||||
id: string
|
||||
taskId: string
|
||||
conversationId: string
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
type AssistantHeartbeatRun,
|
||||
type AssistantExpert,
|
||||
type AssistantTask,
|
||||
type ExecutionStats,
|
||||
type ExecutionStatsInput,
|
||||
type ActivityHistorySnapshot,
|
||||
type ActivityRecord,
|
||||
type TokenUsageSummary,
|
||||
@@ -1744,6 +1746,7 @@ export type KnowledgeSearchReference = {
|
||||
}
|
||||
|
||||
export type DesktopApi = {
|
||||
localInference: import('./local-inference-contracts').LocalInferenceApi
|
||||
storageUpgrade: {
|
||||
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
|
||||
act: (action: 'retry' | 'quit') => Promise<void>
|
||||
@@ -1907,6 +1910,7 @@ export type DesktopApi = {
|
||||
}
|
||||
updates?: {
|
||||
getSettings: () => Promise<ApplicationSettings>
|
||||
onSettingsChanged: (listener: (settings: ApplicationSettings) => void) => () => void
|
||||
updateSettings: (
|
||||
input: ApplicationSettingsUpdate
|
||||
) => Promise<ApplicationSettings>
|
||||
@@ -2107,6 +2111,7 @@ export type DesktopApi = {
|
||||
}
|
||||
tasks: {
|
||||
list: () => Promise<AssistantTask[]>
|
||||
getExecutionStats: (input: ExecutionStatsInput) => Promise<ExecutionStats>
|
||||
setStatus: (
|
||||
taskId: string,
|
||||
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user