Compare commits

...
25 Commits
Author SHA1 Message Date
mesalogo 8280158edc test: preserve native focus sequence when discarding note drafts
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Wait for detail navigation and continued editing focus, and focus the cancel button before synthetic clicks so confirmation restores the actual opener. Keep draft discard and final entry focus assertions unchanged. Validate in CI without touching parallel work.
2026-10-02 12:14:33 +08:00
mesalogo 5368e0ecf4 test: align fixtures with image tools and enabled browser defaults
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Isolate Obsidian tests from the default browser capability, check the additional image-tool card, and center native overlay click targets below the frosted header. Retain existing permissions, identity, hit-testing and keyboard assertions; preserve unrelated uncommitted work.
2026-10-02 11:53:21 +08:00
mesalogo 20317c21b5 release: prepare Desktop 0.15.8 and Agent 0.15.2
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Release committed image-save tools, note editing improvements, and enabled-by-default browser and frosted appearance preferences. Include bounded Linux Host save evidence and bilingual Desktop/Agent notes. Preserve all pre-existing uncommitted design and article files; full validation and packaging run in CI.
2026-10-02 11:31:43 +08:00
mesalogo b1129cedec fix: enable frosted glass effect by default 2026-10-02 10:40:16 +08:00
mesalogo 2e5c348dce fix: enable built-in browser by default 2026-10-02 10:27:58 +08:00
mesalogo f4d8fe7f06 fix: add new-entry cancel, full-width note content, and restore focus in magic notes
- Add a Cancel action to the new-entry composer with discard confirmation
- Remove the 880px cap so note content matches the toolbar width
- Focus the chat composer only after a deferred workspace leave commits
- Return focus to a sensible target after cancelling new/edited entries and switching entry type
2026-10-02 10:20:04 +08:00
mesalogo dd29f309f1 feat: add save_image tool and list image MCP tools
Expose save_image across direct model, MCP gateway, DeepSeek Harness and remote Agents (written on the remote host via chunked reads), and show the image MCP tool list in settings.
2026-10-02 10:08:42 +08:00
mesalogo b02ce31886 test: align native composer hints and bound Markdown corpus checks
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Update the Electron probe for the two-line shortcut placeholder after greeting removal. Preserve all prefix-equivalence checks and give only the exhaustive Markdown corpus test 15 seconds. Validate through CI per user instruction.
2026-10-01 22:43:33 +08:00
mesalogo 7cb74cce78 test: account for sticky settings headers in layout checks
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Scroll settings controls into the unobscured area below the measured sticky header, retaining native interaction and hit-testing assertions. Give the full legacy outbox migration fixture a scoped 15-second timeout. Product code and bilingual release notes are unchanged; validation remains in CI.
2026-10-01 22:13:42 +08:00
mesalogo 5e77118508 fix: remove redundant chat input placeholder greeting 2026-10-01 22:01:47 +08:00
mesalogo 227507de8f release: prepare Desktop 0.15.7
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Document streaming rendering improvements, on-demand activity refresh, local custom MCP failure isolation, managed model source selection, and opt-in device sharing. Agent remains 0.15.1 and schema remains 52. Per user instruction, validate the exact candidate through CI without additional local tests.
2026-10-01 21:54:32 +08:00
mesalogo c8f327fc4e perf: reduce streaming render cost and refresh activity page on demand
- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs
- Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals
- Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button
- Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree
- Add Electron streaming input benchmark and regression tests
2026-10-01 21:49:09 +08:00
mesalogo 016f987026 test: assert note focus before opening creation form
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Check detail navigation focus before the auto-focused creation input takes over. Preserve return-to-list and reading-position assertions, and include the reviewed sharing first-read test and independent validation record. Validation runs in CI per user request.
2026-10-01 09:16:15 +08:00
mesalogo 2c5300d031 chore: finalize expanded desktop 0.15.6 release scope
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Include device-sharing metadata and the persistent New note entry in bilingual release notes, align five-app documentation, and update the configuration fixture. Per user instruction, validation proceeds through CI without more local tests. Agent remains 0.15.1.
2026-10-01 01:43:53 +08:00
mesalogo a3b41bf2b1 feat: add device sharing metadata application 2026-10-01 01:31:44 +08:00
mesalogo 0a02a17cb6 fix: keep New note in the magic notes page header 2026-10-01 01:31:43 +08:00
mesalogo d2ceff8db5 test: allow the long-history warm-switch regression to finish
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Scope a 15-second budget to the 161-message switching scenario while retaining all render-count, DOM reuse, draft, attachment, and scroll assertions. The full App suite, typecheck, and lint pass.
2026-10-01 01:15:10 +08:00
mesalogo 8201c9c27e test: wait for theme transitions before comparing glass styles
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The Linux full suite sampled a window-control color during its transition. Wait for CSS transitions to finish while retaining exact style, contrast, geometry, and native interaction assertions.
2026-10-01 00:48:35 +08:00
mesalogo cc451f698d release: prepare Desktop 0.15.6
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Include the pending sidebar footer style correction and document frosted appearance, chat rendering, image previews, settings layout, and local MCP recovery. Record schema 52 backup guidance and 886 passing focused tests; Agent remains 0.15.1.

Release note: 新增可选磨砂外观,改善聊天输入、图片预览和本地工具连接恢复,并整理设置与笔记布局。
2026-10-01 00:23:54 +08:00
mesalogo 9cc5dc1244 fix: retire uncertain MCP names so late OpenCode adds cannot replace live connections 2026-10-01 00:08:50 +08:00
mesalogo f7f501a8e3 feat: add frosted glass appearance and improve local MCP recovery
Add an opt-in persistent frosted glass setting for the top bar and runtime checklist, with chat scrolling behind the header while preserving scroll anchors and window controls.

Retry transient local MCP initialization failures once after cleanup, include redacted connection diagnostics in feedback, and index lifecycle events to reduce execution statistics scans.

Release note: Add optional frosted glass appearance with chat visible behind the title bar; improve local tool connection recovery and diagnostic feedback.
2026-09-30 23:37:26 +08:00
mesalogo e4f7b8d993 fix: group settings navigation and align switches to the right 2026-09-30 23:33:25 +08:00
mesalogo b6d24ada87 fix: preview draft and queued image attachments directly
Make draft thumbnails and queued images open the shared image viewer without parsing. Keep queued attachment menus read-only and remove OCR source and settings entries while preserving explicit text extraction in drafts.

Validation: 14 focused tests, typecheck and lint passed. Full suite: 5534 passed, 3 failures outside this change in conversation scrolling and frosted-glass descriptions.

Release note: 草稿和队列附件中的图片现在支持点击放大查看,无需 OCR;队列只读菜单不再显示解析来源和解析设置。
2026-09-30 23:18:45 +08:00
mesalogo c23dca65e8 fix: reduce chat typing and cached conversation switching overhead
Keep unchanged history panes and task strips from rerendering during typing, and stabilize message callbacks across cached conversation switches. Preserve streaming updates, scroll positions, drafts, and asynchronous attachment ownership.

Add App regressions for render isolation, warm switches, retry, and image recovery. Focused tests, typecheck, and lint passed; the full suite encountered unrelated settings failures.

Release note: 减少任务运行时输入和切换已缓存长会话的重复渲染,保留实时输出、滚动位置和会话草稿。
2026-09-30 22:59:25 +08:00
mesalogo bf64181c85 fix: align magic note headers and editor spacing
Align the notes list, detail toolbar and comment direction row, removing stacked top padding. Let text editing controls fill the record card while preserving text and action padding and canvas spacing.

Add Electron geometry regressions and document the spacing rules. Release note: Magic Notes now aligns pane headers and removes excess gaps around text editing toolbars and input areas.
2026-09-30 22:31:30 +08:00
152 changed files with 7540 additions and 1512 deletions
+49 -8
View File
@@ -57,7 +57,7 @@ records are listed separately and do not introduce another feature status.
- [x] **Optional desktop task notifications**: A default-on switch under
Platform Features / General immediately controls system task reminders.
In-app notifications remain available independently; General settings group
shortcuts, conversations and notifications, and workspace and downloads.
shortcuts, conversations and notifications, and the working directory.
- [x] **On-demand contextual help**: Supplementary page and settings
explanations use shared title- or field-adjacent help controls with mouse
and keyboard access. Important operation consequences and errors remain visible.
@@ -149,10 +149,12 @@ records are listed separately and do not introduce another feature status.
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
open their main content pages. Manage Apps uses a LayoutGrid icon and opens Application Center with searchable cards and settings details;
layout and interaction rules are defined in the [UI design](./docs/features/application-tool-navigation/ui-design.md).
All four application cards support arrow and drag reordering regardless of enablement or pinning.
All five application rows support arrow and drag reordering regardless of enablement or pinning.
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
is always enabled and pinned, with Open and reorder controls. Supervisor, Notes,
and Local Inference Monitor have enablement and pinning settings. Supervisor uses
and Local Inference Monitor have enablement and pinning settings. Device Sharing
is optional and off by default during testing, with an enable switch but no pinning.
Supervisor uses
the existing Heartbeat identity and defaults to off when no preference is saved;
explicit saved choices are preserved. Disabling it retains plans and history,
blocks new reviews, and hides sidebar feedback; in-flight work may finish.
@@ -171,6 +173,16 @@ records are listed separately and do not introduce another feature status.
local-engine checks, and remaining platform/package coverage. Historical
failures are not the current full-suite result. See the
[validation progress](./docs/features/application-tool-navigation/progress.md).
- [x] **Local device-sharing metadata** (implemented in source): Enable Device Sharing
in Application Center first; it is off by default during testing. Saved explicit
enablement is preserved. Disabling leaves the page without clearing catalog records
or revoking publications. Open it from Apps or Application Center, save a service URL and device name, register the
device, browse returned records, publish capability or knowledge metadata, and
revoke this device ID's entries. The default service is `http://127.0.0.1:8787`.
This unauthenticated local service uses IDs for identification only. Server source
mode does not upload or sync content, and independent knowledge search/read/download
flags are declarations, not content access or remote execution. See
[scope and validation](./docs/features/device-sharing/README.md).
- [ ] **Privately deployable application marketplace** (planned): Extends
application management with an organization-owned catalog and application
distribution. Current management covers built-in apps only, not marketplace
@@ -333,7 +345,7 @@ records are listed separately and do not introduce another feature status.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.15.1`, paired with the current Desktop release candidate `0.15.5`; formal
`0.15.2`, paired with the current Desktop release candidate `0.15.8`; formal
publication status follows the separate Agent and Desktop
release channels. Previous macOS validation covered native package installation,
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
@@ -920,9 +932,12 @@ records are listed separately and do not introduce another feature status.
- [x] **WeCom and DingTalk connections**: Main-only encrypted settings,
read-only environment-variable overrides, connection tests, dynamic
enable/disable, sender scope, and status diagnostics.
- [x] **Managed local-model download source**: Platform Features / General
selects ModelScope (default) or Hugging Face globally for future speech-input
and OCR model downloads. A source without a complete verified file is
- [x] **Managed local-model download source**: The Model Connections header shows
the current source and a secondary button, right-aligned on the same row as the
model-type tabs below Save. Its dialog selects ModelScope (default) or Hugging Face
globally for future managed local-model downloads; changes save only on Confirm,
Cancel leaves the saved source unchanged, and errors allow retry. Embedding, speech,
and OCR source links open this dialog directly. A source without a complete verified file is
explicitly unavailable and never silently falls back or combines files.
- [x] **Optional local speech-model management**: Model weights are not bundled.
Provides verified downloads, progress and cancellation, source links, ZIP or
@@ -951,9 +966,35 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases
- The current Desktop candidate is `0.15.5`; published Agent `0.15.1` is unchanged, with
- The current Desktop candidate is `0.15.8`; Agent candidate `0.15.2` requires Desktop `0.15.8`, with
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
Desktop and Agent release channels.
- Desktop `0.15.8` and Agent `0.15.2` add Execute-only saving of conversation
images to absolute local or Host paths, with explicit overwrite and no WebP
conversion. MCP settings list image tools without promising availability in
every mode. Notes gain new-entry cancellation, edit-focus restoration and full
available content width. Missing browser and frosted-appearance preferences
default to enabled; saved choices remain unchanged. Schema stays at 52.
Real Linux x64 Host tool-layer evidence and remaining exact-candidate CI and
package checks are separated in the
[preparation record](./docs/development/release-preparation-0.15.8.md).
- Desktop `0.15.7` reuses completed streaming Markdown blocks and conversation
derived values without changing the default streaming cadence. Activity records
load on entry, refresh every 15 seconds while visible, and offer manual refresh.
Local custom MCP discovery failures are isolated per service. Managed model
source selection moves to a confirmation dialog in Model Connections, and Device
Sharing becomes an opt-in test feature. Schema remains 52. Exact-candidate CI
and packaging are still required; component benchmarks are not full-App results.
See the [preparation record](./docs/development/release-preparation-0.15.7.md).
- Desktop `0.15.6` adds optional, persisted frosted-glass header/checklist surfaces
(off by default, without OS desktop transparency), reduces repeated chat renders,
and supports direct previews for draft/queued images. Settings are grouped with
right-aligned switches, and note headers/editor spacing are tighter.
Local OpenCode retries explicit transient MCP transport failures once after
cleanup and retires uncertain names; it does not replay prompts or tool calls.
Redacted MCP diagnostics support feedback. Schema 52 adds execution-statistics
indexes; back up the database and notes before upgrading.
See the [preparation record](./docs/development/release-preparation-0.15.6.md).
- Desktop `0.15.5` updates Smart heartbeat and incremental review, preserves
cancelled-turn history, floats checklist details, and exits note editing after
saving. It also adds manual note tags with AND filtering and rename/merge/delete,
+9 -5
View File
@@ -23,7 +23,7 @@
后台会话成功完成后,在本次客户端运行期间保留完成提示,直到对应聊天可见且加载完成;提示不跨重启保留。
- [x] **会话置顶与完整历史**:常用会话可置顶,重启后保留。列表和搜索不再限于最近 100 个会话,连续发送不再截断超过 500 条的已保存历史;模型上下文限制仍有效,已丢失的历史无法恢复。
置顶与非置顶分组按最近一条消息的创建时间排序,流式文本和工具进度更新不再反复改变位置。
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录与下载分组。
- [x] **可选桌面任务通知**:“平台功能 / 通用设置”提供默认开启、保存后立即生效的系统任务提醒开关,应用内提示独立保留;通用设置按快捷键、会话与通知、工作目录分组。
- [x] **按需上下文帮助**:页面和设置中的补充说明使用标题或字段旁的共享帮助入口,支持鼠标与键盘;关键操作后果和错误提示保持可见。
- [x] **紧凑会话控件**:输入选项集中在紧凑设置面板,Runtime、工作模式、发送和队列操作保持可用;会话搜索提供内嵌清除按钮,清除后恢复当前范围列表并把焦点返回输入框。
- [x] **会话历史按需加载**:列表只加载轻量摘要,不再一次读入全部会话的过程元数据;已打开和活动会话保留完整详情,搜索、复制、导出、续聊和远程待答问题保持可用。图片合并不会重新发送未变化消息,已确认保存的空闲详情随现有视图缓存释放。详见[历史读取与保留规则](./docs/features/assistant-workbar/execution-history-storage.md#会话列表读取与前端保留)。
@@ -36,7 +36,8 @@
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索卡片及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。四张应用卡片均支持上下移动和拖动排序,不受启用或常驻状态限制;卡片、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [x] **应用中心与导航**:底部“应用”和“设置”为显示文字的等高按钮,中间使用弱竖分隔线。“应用”带向上箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”使用 `LayoutGrid` 图标,打开标题仍为“应用中心”的可搜索列表及设置详情,布局与交互见 [UI 设计](./docs/features/application-tool-navigation/ui-design.md)。五个应用行均支持上下移动和拖动排序,不受启用或常驻状态限制;列表、菜单和经过过滤的侧栏共用一份保存顺序。知识库始终启用并常驻,提供“打开”和排序;监督者、魔法笔记和本机推理监控可设置启用与常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关。监督者沿用智能心跳身份,缺失启用设置时默认关闭,已明确保存的选择保留;关闭后保留计划和历史,拒绝新回顾并隐藏侧栏反馈,在途运行可完成。启用应用不会自动创建计划。旧应用顺序补齐默认项并保留已有相对顺序。设置持久化后的事件同步各入口,包括工具配置写入;重开中心刷新期间锁定修改,新快照覆盖迟到的旧读取。`local-inference` Modal 直接显示服务列表,不展示任务历史或外部连接;向量服务显示独立进程 CPU 与工作集内存,ASR/OCR 说明共享进程无法独立统计的原因。ASR 就绪状态未知,不从活动请求推断运行中。受支持的服务操作保留影响确认,失败后刷新列表并要求重新确认;TTS 显示不可用。取消中的推理在 Worker 确认或退出前仍计入活动任务及服务停止影响。实现已接入生产 App;验收记录分别列出完整 App、组件 fixture、真实本地引擎和剩余平台/安装包覆盖,不以历史失败代替当前全量结果,详见[验证进度](./docs/features/application-tool-navigation/progress.md)。
- [x] **本地设备共享元数据**(源码已实现):测试阶段默认关闭,先在应用中心启用,再从应用菜单或应用中心打开;已明确保存的启用选择保留,关闭会退出页面,但不清除目录或撤销已发布记录。可保存服务地址和设备名称、注册本机、浏览服务返回的目录、发布能力或知识元数据并撤销本机 ID 的条目。默认服务为 `http://127.0.0.1:8787`。服务未认证,ID 只用于识别;服务器来源不上传或同步内容,知识搜索/读取/下载标志是独立声明,不提供正文访问或远程执行。详见[范围与验证](./docs/features/device-sharing/README.md)。
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [FR-15](./docs/features/application-tool-navigation/prd.md#fr-15-私有化市场与-yaml-交换后续)。
- [ ] **通用助手工作栏与执行空间后续能力**(规划中):在现有工作栏和多终端基础上继续加入更完整的执行监督、统一 Runtime 监控、受管进程、可固定目标的工作区/浏览器/成果实例、底部停靠与独立窗口。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)。
@@ -52,7 +53,7 @@
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 的原生参数发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 限于工作区只读搜索和读取,Execute 按当前账号权限搜索;用户无需另行安装 ripgrep。搜索保留原生输出与退出码,大结果通过 `output_read` 完整续读,可处理的参数错误允许模型调整后继续。
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的搜索、命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.1`,配套 Desktop 候选为 `0.15.5`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖。当前 Agent 源码锁为 `0.15.2`,配套 Desktop 候选为 `0.15.8`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
@@ -145,7 +146,7 @@
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
- [x] **受管本地模型下载源**:在“平台功能 / 通用设置”中为后续语音输入与 OCR 模型下载全局选择 ModelScope(默认)或 Hugging Face;所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
- [x] **受管本地模型下载源**:“模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源与次按钮。弹窗为后续受管本地模型下载全局选择 ModelScope(默认)或 Hugging Face,确认才保存,取消保持已保存来源不变,错误可重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。所选来源缺少完整已验证文件时明确不可用,不静默换源或混合文件。
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
@@ -156,7 +157,10 @@
### 开源、构建与发布
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
- 当前 Desktop 候选为 `0.15.5`,已发布 Agent 保持 `0.15.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 当前 Desktop 候选为 `0.15.8`,Agent 候选为 `0.15.2`、最低要求 Desktop `0.15.8`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.15.8` 与 Agent `0.15.2` 支持 Execute 模式保存会话图片到本机或 Host 绝对路径,覆盖需明确指定,不支持转换为 WebP。MCP 设置列出图像工具,不代表所有模式均可用。笔记增加新记录取消、编辑焦点返回及可用内容全宽;缺少浏览器和磨砂设置时默认开启,已保存选择不变。schema 仍为 52。真实 Linux x64 Host 工具层证据与待完成的精确候选 CI、打包验证分别记录在[发布准备](./docs/development/release-preparation-0.15.8.md)。
- Desktop `0.15.7` 复用已完成的流式 Markdown 块与会话派生值,不改默认流式节奏。运行记录进入时读取、可见时每 15 秒刷新,也可手动刷新;本地自定义 MCP 发现故障按服务隔离。受管模型下载源移至模型连接中的确认弹窗,设备共享改为显式启用的测试功能。schema 仍为 52,仍需精确候选 CI 与打包验证,组件测量不等同完整 App 结果。详见[发布准备](./docs/development/release-preparation-0.15.7.md)。
- Desktop `0.15.6` 增加默认关闭、可保存的顶栏与清单磨砂外观,不透出系统桌面;减少聊天重复渲染,草稿与队列图片支持直接预览,设置分组及开关右对齐,笔记标题和编辑间距更紧凑。本地 OpenCode 在清理后对明确暂时性 MCP 传输错误最多重试一次,作废结果不确定的名称,不重放 Prompt 或工具调用;反馈包含脱敏连接诊断。schema 52 增加执行统计索引,升级前备份数据库和笔记。详见[发布准备](./docs/development/release-preparation-0.15.6.md)。
- Desktop `0.15.5` 更新智能心跳与增量回顾,保留中断轮次历史,执行清单改为浮层,笔记保存成功后退出编辑;自动评论不重新打开编辑器,失败保留草稿。新增手动笔记标签、AND 筛选及重命名/合并/删除,双栏笔记与全宽概览,固定工作区文件操作栏,缓存 OpenCode 终端依赖并缩短 MCP 名称。schema 51 增加标签存储,同时迁移旧待确认记忆为待处理约定;升级前备份数据库与相邻笔记。证据见[发布准备](./docs/development/release-preparation-0.15.5.md)。
- Desktop `0.15.4` 增加 Runtime/模型分层选择及独立系统任务用量,减少 Runtime 指令和历史重复。schema 49 迁移项目与会话选择,回退须恢复完整升级前数据库和相邻笔记备份。Agent `0.15.1` 要求 Desktop `0.15.4`,修复远程 Continue 历史重复封装,Runtime 锁版本不变。证据见[0.15.4 发布准备](./docs/development/release-preparation-0.15.4.md)。
- Desktop `0.15.1` 已完成六平台原生打包及 GitHub/OSS 发布。Desktop `0.15.2` 新增 Story Graph 只读工具和完整可编辑的监督讨论消息,简化笔记采集并补充退出诊断;远程图谱读取需配套 Agent `0.15.0`。主机与云环境管理应用仍为待实现规划,当前候选证据见[0.15.2 发布准备](./docs/development/release-preparation-0.15.2.md)。
+18 -8
View File
@@ -43,6 +43,10 @@
两种主题必须保持相同的信息层级,但不要求机械地反转明暗。浅色主题以白色主内容画布、冰蓝灰侧栏和轻微着色的顶栏建立空间关系;深色主题使用深海军蓝与蓝灰表面逐层提亮,避免纯黑。蓝色承担主要选择和交互,青绿色主要承担成功与可用状态,二者不得混用语义。
外观设置提供“透明磨砂特效”开关,默认开启,文字在左、共享 Switch 在右。新用户及缺少该字段的旧配置使用开启状态;已保存的开关选择保持不变。选择保存在应用设置中,保存成功后立即生效,重启后恢复。开启时,右侧内容框架顶栏的背景不透明度在浅色主题为 65%、深色主题为 70%,保留 18px 背景模糊与淡分隔线。聊天滚动视口向上延伸 58px,消息上滚时实际经过顶栏背后;不使用人工渐变充当背景。初始留白放在滚动内容中,包含顶栏高度和当前任务/待办条的实测高度,首条消息仍从这些控件下方开始。任务/待办条固定在顶栏下方并保持实色;展开的 Runtime 待办卡片使用两主题各自的 75% 不透明背景、18px 背景模糊和清晰的文字颜色。
已保存的开关状态同步到文档根节点,卸载时清理,使 body/top layer 中的待办卡片也能应用样式;其他菜单保持原有表面。输入区、右侧工作栏和非聊天页面保持原有位置,聊天加载态及无活动消息面板时不扩展视口。窗口拖拽和控制按钮保持原有行为;该效果不透出操作系统桌面。关闭后恢复顶栏下方的聊天布局,顶栏与待办卡片均恢复实色。`tests/frosted-glass.electron.test.ts` 使用真实 `ChatHistoryPane` 检查消息与顶栏的可见矩形交叠、初始留白、关闭恢复、浅深主题、跳底、历史加载锚点和会话切换。
## 3. 设计令牌
令牌以 CSS 自定义属性实现。`:root` 提供浅色值,`[data-theme="dark"]` 覆盖深色值。组件样式不得新增只服务于单个页面的颜色、阴影、圆角或间距常量。
@@ -333,9 +337,9 @@
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
五个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。监督者沿用智能心跳身份,可修改启用和常驻,执行边界见[监督者逻辑](./docs/features/conversation-supervision/logic-design.md#应用启停与执行)。魔法笔记、本机推理监控同样可修改启用和常驻。设备共享在测试阶段默认关闭,标签为“可选”及启用状态,提供启用设置和排序,不提供常驻开关;启用后可从应用菜单及管理 Modal 在主内容区打开元数据页面,关闭时退出该页,规则见[设备共享 UI](./docs/features/device-sharing/ui-design.md#当前首期页面)。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 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 助手工作栏
@@ -488,6 +492,7 @@ Switch 用于在两个持久状态之间立即切换,例如启用能力、开
- Checkbox 保留原生 Checkbox 语义和方形勾选视觉,不得添加 `role="switch"`。多项分配、列表选择、确认声明和“保存时清除密钥”等一次性选择均属于 Checkbox。
- 不创建页面专属 Switch 样式。需要紧凑布局时仍复用同一轨道、滑块、焦点环、禁用状态和动效,只调整共享组件支持的布局变体。
- Switch 支持 Tab 聚焦和 Space 切换,键盘焦点至少显示 `2px` 高对比焦点环。可见标签应描述被控制的能力,不能只显示“开 / 关”。
- 共享开关行统一为标签在左、Switch 靠右,说明放在标签下方;不因 JSX 中输入控件与文字的先后顺序改变视觉位置。窄窗口允许文字换行,开关保持固定尺寸。Checkbox 和 Radio 保留各自的选择布局。
- 异步切换期间禁用重复操作并保留原状态。失败时恢复或保留最后确认状态,通过应用通知或就地可恢复错误说明原因。
- 涉及联网、上传、电脑控制或其他外部影响的 Switch,附近必须持续说明数据去向、权限范围或风险,不能只靠设置名称表达影响。
- 自动化测试应按 `switch` 角色查询二元开关,按 `checkbox` 角色查询多选或确认项,防止视觉迁移后语义回退。
@@ -789,7 +794,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 魔法笔记设置提供“发送画布页数”单选框,复用设置表单 `.field` 控件样式,范围 1~8,默认 1。标题同行的问号使用 `InlineHelp`,下拉框通过稳定的帮助 ID 关联展开的说明,交互遵循 6.14。说明包含当前页序前 N 页、不足时发送全部页、数量指单个画布页数而非记录数,以及完整保存与导出不受影响。修改后沿用设置保存反馈,下次分析生效。
- 草稿、已保存画布记录及来源为画布的待办,按默认模型的 `supportsImageInput` 使用所选前 N 页图像与文字,或只发送同一范围文字并标注“仅文字分析(未使用画布图像)”。所选页只有视觉内容的记录/草稿遇到文字模型时提示切换支持图像输入的默认模型,不声称已理解笔迹或布局。待办分析同时保留任务标题、说明和所选来源画布页的上下文。
- 画布文字评论在提取的 `plainText` 不变时保留;图像评论随画布内容或布局变化失效,在保存后自动评论模式下重新分析。草稿内容变化清除旧草稿分析,评论区显示实际使用的输入模式。存储和判定细节见[画布技术设计](./docs/features/magic-notes/technical-design.md#canvas-analysis-and-comments)。
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、新建、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
- 总览是全宽的“全部笔记”页,以自适应卡片网格(最小 `260px`)显示全部笔记,不显示右侧空白详情,也不自动打开第一篇;卡片显示标题、短日期、两行预览和记录数。打开笔记后切为左侧紧凑列表与右侧详情,页头右上角显示带文字的“返回总览”,点击回到全部笔记页并恢复搜索、滚动位置与卡片焦点;“切换到待办”只在总览显示。列表按紧凑两行行项显示:第一行为标题与短日期(今天显示时间,其余显示月/日,完整时间在工具提示中),第二行为单行预览;记录数只提供给读屏。行项无边框、透明底色,悬停为次级底色,选中为淡蓝底色加左侧 `3px` 强调标记;“更多”按钮悬停或聚焦时覆盖在日期位置。保留搜索、置顶菜单和当前选中状态。打开笔记只更新右侧详情;列表独立滚动,切换保留搜索和列表位置,并在工作区挂载期间记住各篇笔记的阅读位置。明确定位某条记录时优先跳到该记录。
- 笔记列表默认展开、宽 `280px`,支持拖动分隔线在 `240–420px` 内调宽,方向键每次调整 `16px`,Home/End 跳到边界;桌面端详情页提供列表显隐开关,记住宽度和展开偏好。工作区宽度不超过 `900px` 时切换显示列表与详情,返回恢复列表位置和入口焦点,不覆盖桌面偏好。新建成功后打开新笔记;切换、新建和删除当前笔记都保护未保存的正文与标题。
- 待办页是独立任务视图:搜索任务标题、说明或来源笔记,搜索框右侧同一工具栏放置“全部 / 未完成 / 已完成”筛选,显示匹配数与总数;任务按来源笔记分组。列表独立滚动,页头、搜索和筛选保持可见,窄屏工具栏空间不足时换行。直接勾选完成或恢复未完成,不自动选择任务;状态变化后按当前筛选更新列表。
- 待办内容容器宽于 `700px` 时,任务列表稳定在左侧,右侧显示当前任务说明、来源记录和 AI 评论;未选择时右侧提示选择任务。点击任务只更新右侧,不改变列表宽度、行位置或滚动位置,再次点击取消选择。AI 不增加第三栏。容器不超过 `700px` 时,按需详情替换同一内容区域,并提供“返回待办列表”;列表保持挂载,返回恢复滚动和任务行焦点,不将列表与详情上下堆叠。任务按钮提供 `aria-expanded` 和 `aria-controls`。
@@ -797,6 +802,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 待办 AI 评论保留方向选择、流式输出、重新分析和已有结果;来源加载失败通过应用通知提示,并提供就地重试。从待办打开原笔记后,“返回待办”恢复任务选择、搜索、筛选和列表滚动;宽屏焦点返回任务行,窄屏返回待办详情中的返回按钮。
- 笔记详情主区按新到旧连续展示全部记录。打开笔记即在标题下、历史记录上显示空白输入区,保留文字/画布类型选择和保存操作,标题栏与索引均不提供“新增记录”按钮。新增保存成功后重建空白输入区,保留所选类型;自动分析结束也不转入旧记录编辑。旧记录通过显式编辑入口修改,保存使用同一 ID 和返回的修订号。点击索引或当前笔记内的 AI 来源只按 entry ID 滚动定位,不卸载编辑器、不清草稿、不询问离开。空白输入区离开不提示,返回总览、打开其他笔记或改为编辑另一条记录时仍保护未保存修改。
- 打开笔记后页面一级标题仍为“魔法笔记”;笔记标题只在记录流顶部出现一次,使用 `22px` 的可编辑大标题,悬停显示下划线,聚焦显示强调色下划线,保留失焦保存、Enter 与 Escape。标题上方为吸顶的细工具栏,依次放置收起笔记列表、记录索引、条数与更新时间、AI 评论开关,均为带工具提示和可访问名称的图标按钮。记录流左右留白 `24px`(窄于 `600px` 时 `12px`),正文最大宽度 `880px` 并居中;吸顶工具栏不受此限制,始终铺满记录流宽度,AI 开关贴在右边缘,底部分隔线横贯整栏。
- 桌面笔记详情的列表标题行、正文工具栏与 AI 评论方向标签行顶部对齐,首行统一使用 `40px` 控件高度并垂直居中;AI 区域不叠加面板和表单的顶部留白。
- 文字记录的新建与编辑区域铺满卡片内沿,格式工具栏紧接头部,输入区域紧接工具栏;编辑器外层不再增加内边距或圆角。正文保留编辑器内部的文字留白,底部操作栏独立保留内边距与顶部分隔线;画布仍保留 `12px` 内容间距。
- 新建记录输入区空闲时收成一行虚线提示框,隐藏类型切换、格式工具栏和保存栏;聚焦、已有草稿、选择画布或出现校验错误时展开,初始约四行并随内容增高。保存成功或切换笔记后恢复为一行。此规则不作用于旧记录编辑器。
- 已保存记录的 AI 分析、编辑、删除为 `28px` 图标按钮,精细指针设备上悬停或键盘聚焦记录时才显示;删除悬停时使用危险色,确认条保持原样。粗指针设备始终显示。笔记内部列表、记录流、AI 区域和文字编辑区在精细指针设备上使用细原生滚动条,轨道透明;强制颜色与粗指针设备保留系统默认。说明、摘要和输入提示使用 `--font-body` 与 `--text-secondary`,时间和短标签保留 `--font-caption`。
- 左侧记录索引默认收起,只在用户展开后记住展开状态;展开宽度默认 `168px`,可拖动右边界在 `140–320px` 内调整栏宽,缩略图随列宽等比适配。开关位于笔记工具栏,提供 `aria-controls`、展开状态和动作提示;收起后完全移除索引列、分隔条、间隙、边框及轨道,不占宽度,隐藏内容不可聚焦。右侧 AI 评论独立显示、滚动和收起,不与索引共享页签。每条记录一个导航项:画布显示真实首页缩略图及总页数,文字显示摘要,两者均显示类型和时间。两侧分别记住桌面展开状态和宽度,共用原有布局偏好;宽布局分隔条共用样式及 `separator` 语义,方向键每次调整 `16px`,Home、End 跳到当前上下限。两侧宽度按可用空间协调限制,为主区保留至少 `300px`,窗口缩小时不覆盖保存的偏好。内容容器不超过 `800px` 时索引默认隐藏,由同一标题栏按钮开闭标题栏下方固定 `168px` 的抽屉,不显示调宽分隔条;标题栏自身保留控件高度,不保留轨道。选择记录或按 Escape 后关闭,Escape 将焦点返回开关。AI 移至底部,高度不超过可用布局的 40% 且最多 `280px`,不显示分隔条。
@@ -806,7 +813,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 画布新建、编辑和只读态统一保留 `12px` 内容间距;详情中已保存画布去掉预览外框和工具栏底色、下划线,保留灰色画布衬底与白色纸张,编辑态保留编辑器边框。记录头部操作按内容宽度排列,空间不足时自然换行。文字新建的保存区与编辑器属于同一卡片,空白编辑区使用最小高度,不随容器百分比高度拉长。文字工具栏图标、菜单和原生滚动条须跟随主题。
- 标签使用中性胶囊样式(`--surface-subtle` 底色、`--border-subtle` 边框、`--font-caption`),不使用颜色区分。总览搜索框下方是标签筛选行:“全部”加各标签和对应篇数,均为 `aria-pressed` 切换按钮;可以多选,多选时只显示同时带有全部所选标签的笔记。选中时使用 `--accent-subtle` 底色和强调色边框。卡片最多显示 3 个标签,其余合并为“+N”。打开笔记后,左侧列表不显示筛选行,只显示“已筛选:A · B”提示条和清除按钮。
- 笔记标题下方是标签编辑行:已有标签后带 × 移除按钮,末尾是虚线“+ 标签”按钮。点击后展开组合框,按输入内容过滤已有标签,也可以新建。方向键切换选项,Enter、逗号或中文逗号确认,Escape 关闭并把焦点还给“+ 标签”。达到 10 个时,“+ 标签”禁用并说明上限。
- 列表标题行的新建按钮左侧是“管理标签”:总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
- 列表标题行右侧只放“管理标签”,不再放 `+` 新建按钮。“新建笔记”统一放在页头右上角,是主按钮,始终位于“切换到待办 / 切换到笔记”或“返回总览”的右侧。打开笔记时点击,桌面端展开左侧列表并显示标题输入,窄屏先回到列表。“管理标签”在总览显示图标加文字,打开笔记后只显示图标。点击后打开共享 `custom-task-dialog` 对话框,按使用篇数列出标签,每行提供重命名和删除图标按钮。重命名在行内编辑;新名称与已有标签相同时,提示保存后会合并。删除在行内二次确认,并说明涉及的篇数。Escape 依次退出行内编辑或确认,最后关闭对话框。
- 整篇笔记的置顶、取消置顶和删除放在列表行的“更多”菜单,打开详情时仍可操作,标题编辑区不重复提供入口。按钮在悬停、键盘聚焦或菜单展开时显示,触屏设备常显;菜单沿用会话列表样式,通过 Portal 避免滚动区域裁切,支持方向键、Home、End 和 Escape。删除在菜单内二次确认并说明笔记标题及全部记录;删除当前笔记还须处理未保存草稿,删除其他笔记保留当前内容与草稿。打开菜单不切换笔记。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
@@ -823,24 +830,27 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 设置中心以整窗遮罩上的大 Modal 打开,保留底层工作区、会话草稿和助手工作栏,不切换一级页面。固定标题区和右上角关闭按钮,左侧分类与右侧内容各自滚动。关闭后返回原工作区并恢复入口焦点。
- 新建项目与项目设置弹窗固定标题、关闭按钮和底部操作区,仅表单内容滚动;滚动条不进入标题区,关闭按钮保留至少 40 × 40px 的点击区域。
- 项目弹窗通过 Portal 挂载到 `document.body`,避免侧栏内的弹窗与主窗口标题栏拖动区域重叠时,Electron 截获关闭按钮的鼠标输入。
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);极窄窗口隐藏分类辅助说明,仍保留左右布局。内容区在可用空间内伸缩,最大宽度为 `960px`。
- Modal 最大尺寸为 `1200px × 900px`,常规外边距为 `24px`;窗口宽度不超过 `1020px` 或高度不超过 `720px` 时接近全屏,保留 `16px` 外边距。左侧分类宽度依次为 `220px`、`180px`(宽度不超过 `1020px`)、`120px`(不超过 `720px`);窄窗口仍保留左右布局,长分类名称可换行。内容区在可用空间内伸缩,最大宽度为 `960px`。
- 设置 Modal 通过 Portal 挂载到 `document.body`,复用共享背景隔离与焦点恢复机制。打开后聚焦关闭按钮,Tab 在弹窗内循环;Escape 先交由嵌套弹窗处理,设置自身的 Escape 与关闭按钮共用未保存确认,确认出现后再按 Escape 返回编辑。点击遮罩不关闭;保存、测试和清理数据期间禁止关闭。每次打开重新挂载设置,模型和消息通道等直达入口使用本次指定分类,普通入口默认打开 Agent Runtime。
- 设置中心不显示全局操作页脚,避免重复关闭入口和没有功能意义的整宽分隔线。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与说明来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
- 所有分类使用共享的 `SettingsCategoryHeader` 呈现分类标题、说明、错误与操作,不得在内容卡片内复制分类标题或创建页面专属操作栏。左侧分类名称与右侧标题来自同一份分类定义,新增分类时不得分别维护导航和内容标题。
- 左侧按“常规 / 模型与执行 / 功能与扩展 / 系统”分组,顺序与键盘导航一致。分类按钮最小高度 `40px`,只显示名称;分类说明集中在右侧页头,外层标题不再重复功能简介。组名使用辅助字号,普通项使用正文和中等字重,选中项使用强调底色与加粗文字。
- 设置区块使用共享圆角、间距和表面令牌;标题为 `--font-section-title`,持久说明至少为 `--font-caption`。嵌套设置区块以顶部细分隔线和留白组织,不叠加卡片背景与四周边框。“能力与工具”的上级页签使用默认 `PageTabs` 外观,与 MCP 内部的分段页签区分。
- 当前分类存在“保存”或“测试”等未提交配置操作时,统一放在分类页头右侧;主保存操作在最右侧,测试等次操作排列在其左侧。
- 自动生效、仅执行即时命令或自行管理编辑流程的分类不显示全局保存操作。窄窗口下操作区可以换行,但保存入口必须保持清晰可见。
- 智能心跳的单条配置不在设置中心重复管理。设置中心如需呈现平台级说明,只提供
“打开智能心跳”导航,不复制创建、暂停、恢复或删除表单。
- 保存或测试成功统一进入应用通知视口,并按全局规则自动消失,不在分类页头或内容卡片中保留持久成功文案。加载、保存和测试错误显示在分类页头下方,并保留可处理的上下文。
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- “模型连接”页头在保存操作下方,将模型类型页签与全局模型下载源入口放在同一行;当前来源文字与“模型下载源”次按钮整体右对齐,窄窗口保持同行,空间不足时导航行可横向滚动。按钮打开挂载到 `document.body` 的 Portal 弹窗,复用共享 Modal 焦点管理;弹窗内使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片,选中状态同时依靠 Radio、边框和背景表达。Radio 只修改草稿,点击“确认”才保存;取消或关闭不改变已保存来源。读取失败提供重试并禁用确认,不以默认值伪装已保存选择;保存失败保留草稿并允许确认重试,保存期间禁止重复提交和关闭。向量、语音和 OCR 的更换下载源入口直接打开模型连接页的同一弹窗,跨分类时沿用未保存修改保护。详细规则见[模型下载源设计](docs/architecture/model-download-source-design.md#7-模型下载源界面)。
- 设置中的连接列表与详情共用 `.model-connection-manager`,按容器可用宽度响应:不超过 `640px` 时上下排列,列表内部滚动高度最多 `180px`;更宽时恢复左右排列。LLM、向量连接和角色编辑复用此规则,不以整窗宽度代替表单宽度。接口协议的中英文当前值须完整可读。回归范围见[窄容器布局修复记录](docs/quality/responsive-controls-review-2026-09-27.md)。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- 通用设置按快捷唤起、会话与通知、工作目录与下载分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- 通用设置按快捷唤起、会话与通知、工作目录分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.15.1",
"agentVersion": "0.15.2",
"protocol": {
"major": 2,
"minor": 0
@@ -12,8 +12,8 @@
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[文档解析与本地 OCR](../features/document-processing/prd.md)、[本地文本向量模型与连接设计](../features/knowledge-base/local-embedding-technical-design.md)、[全双工实时语音交互设计](../features/full-duplex-voice/technical-design.md) |
本文定义“设置 → 平台功能”的二级页签结构,以及所有 GoodBuddy 托管本地模型共同使用的
“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
本文定义“设置 → 平台功能”的二级页签结构,以及位于“设置 → 模型连接”、所有 GoodBuddy
托管本地模型共同使用的“模型下载源”设置。首期下载源为 ModelScope 和 Hugging Face,默认使用 ModelScope。
模型下载源是用户明确选择的供应链路径。当前来源不可用或没有所选模型时,GoodBuddy
必须明确失败并提供设置入口,**不能静默切换到另一个下载源。**
@@ -25,7 +25,8 @@
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
- **通用设置**
- **远程项目**
2. “通用设置”首个功能为“模型下载源”。
2. “模型下载源”位于“模型连接”页头,与模型类型页签同一行右对齐,在保存操作下方;
显示当前来源和次按钮,点击后在弹窗中选择并确认保存。
3. 首期提供两个互斥选项:
- `modelscope`,用户文案为 **ModelScope**,默认值。
- `hugging-face`,用户文案为 **Hugging Face**。
@@ -47,8 +48,8 @@
同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。
8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的
下载使用新来源。
9. 当前来源失败时,不自动重试另一个来源。错误可以提供“前往通用设置更换下载源”,由
用户明确选择。
9. 当前来源失败时,不自动重试另一个来源。向量、语音和 OCR 的更换下载源入口直接打开
“模型连接”页的下载源弹窗,由用户明确选择并确认。
10. 魔法笔记设置由应用中心与笔记页共享,不在全局设置中重复提供入口。
---
@@ -59,10 +60,11 @@
“平台功能”现已使用两个二级页签:
- “通用设置”承载全局模型下载源。
- “通用设置”承载快捷唤起、会话与通知、工作目录设置。
- “远程项目”承载远程项目开关与 Agent 包管理。
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
全局模型下载源由“模型连接”页头提供入口,布局和弹窗行为见第 7 节。
### 2.2 受管模型下载
@@ -99,7 +101,7 @@
- 切换来源时不影响已安装、已选择或正在使用的模型。
- 下载失败时知道实际使用了哪个来源。
- 所选来源没有模型时得到明确说明,并可以主动前往设置切换。
- 在“平台功能”的“通用设置”中选择模型下载源。
- 在“模型连接”页头打开模型下载源弹窗,选择后确认保存。
### 3.2 产品目标
@@ -163,9 +165,12 @@
```text
设置
├─ 模型连接
│ └─ 页头:模型类型页签同行右侧的当前来源与“模型下载源”按钮
│ └─ 模型下载源弹窗
└─ 平台功能
├─ 通用设置
│ └─ 模型下载源
│ └─ 快捷唤起、会话与通知、工作目录
└─ 远程项目
├─ 远程项目开关
└─ Agent 包管理
@@ -221,21 +226,20 @@
---
## 7. 通用设置界面
## 7. 模型下载源界面
### 7.1 布局
通用设置按快捷唤起、会话与通知、工作目录与下载分成三张卡片。模型下载源位于
“工作目录与下载”卡片内的工作目录输入之后;以下仅展示下载源控件,完整分组及通知
开关行为见 [UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
“模型连接”页头在保存操作下方,将模型类型页签与下载源控件放在同一行:页签居左,
“当前来源:ModelScope”和“模型下载源”次按钮整体右对齐。窄窗口保持同行,空间不足时
导航行可横向滚动。点击按钮打开弹窗;通用设置的工作目录卡片不再提供下载源控件。共享布局规则见
[UI 设计规范](../../UI-DESIGN.md#138-设置中心)。
```text
平台功能
管理通用平台行为与可选工作区能力
模型连接 [保存]
[文本] [向量] [重排] [语音] 当前来源:ModelScope [模型下载源]
[ 通用设置 ] [ 远程项目 ]
┌ 模型下载源 ────────────────────────────────────────────┐
┌ 模型下载源(弹窗)───────────────────────────────── [×]┐
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
│ 已安装模型和 ZIP 导入不受影响。 │
│ │
@@ -244,19 +248,19 @@
│ │
│ ○ Hugging Face │
│ 适合可以稳定访问 Hugging Face 的网络环境 │
│ │
│ [取消] [确认] │
└────────────────────────────────────────────────────────┘
```
模型下载源保存属于即时设置,不增加与来源选项竞争的
大型“保存全部”按钮。
弹窗通过 Portal 挂载到 `document.body`,复用共享 Modal 样式、背景隔离和焦点管理。
模型连接页头的保存操作与下载源弹窗的“确认”各自保存对应设置。
### 7.2 控件
来源选项包含说明和未来可能的不可用原因,因此使用语义化 Radio Group,而不是原生
`select` 或 `SegmentedControl`:
- `fieldset` + `legend` 表达“模型下载源”。
- 弹窗内使用 `fieldset` + 持久的屏幕阅读器 `legend` 表达“模型下载源”。
- 每个选项使用 Radio 和整行可点击卡片。
- 当前项同时显示选中 Radio、强调边框和选中背景。
- 不能只用站点 Logo 或颜色表达选择。
@@ -285,12 +289,16 @@
### 7.4 保存交互
- 用户选择另一个 Radio 后立即调用应用设置更新。
- 保存期间禁用两个选项,保留最后确认值。
- 成功后更新当前选择,并通过应用通知提示:
- 打开弹窗时以已保存来源初始化草稿;选择 Radio 只修改草稿,不调用应用设置更新。
- 点击“确认”才保存;草稿与已保存来源相同时直接关闭,不重复写入。
- 取消、关闭按钮、Escape 或点击遮罩均丢弃草稿,已保存来源不变。
- 保存期间禁用选项、确认、取消和关闭,阻止重复提交及遮罩、Escape 关闭。
- 成功后更新页头当前来源并关闭弹窗,通过应用通知提示:
`模型下载源已切换为 Hugging Face。`
- 失败时恢复最后确认值,在 Radio Group 附近显示可重试错误。
- 保存失败时保留弹窗和草稿,已保存来源不变;就地显示错误,允许再次点击“确认”重试。
- 读取失败时显示错误和“重试”,禁用确认,不以默认值伪装为已保存来源。
- 同一个失败不再同时显示页面横幅和全局错误通知。
- 弹窗打开后聚焦关闭按钮,Tab 在弹窗内循环;关闭后焦点回到页头“模型下载源”按钮。
来源变化不触发:
@@ -631,7 +639,7 @@ resolveModelDownloadPackage(files, source)
### 12.1 来源状态
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示模型连接页头中
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
@@ -649,10 +657,12 @@ resolveModelDownloadPackage(files, source)
```text
Hugging Face 暂不提供此模型的完整已验证文件。
[前往通用设置]
[模型下载源]
```
不能显示可点击“下载”后才告诉用户缺少某个文件。
向量、语音和 OCR 的“模型下载源”入口均导航到“模型连接”并直接打开同一弹窗。
跨分类导航沿用设置中心的未保存修改确认,用户取消离开时保留原表单。
### 12.2 已安装模型
@@ -697,7 +707,7 @@ ZIP 是来源无关的离线迁移方式:
- `正在从 {{source}} 下载`
- `打开 {{source}} 模型仓库`
- `当前下载源暂不提供此模型`
- `前往通用设置更换下载源`
- `模型下载源`(打开下载源弹窗)
不再写死:
@@ -743,7 +753,7 @@ ZIP 是来源无关的离线迁移方式:
失败状态提供:
1. 重试当前来源。
2. 前往“平台功能 → 通用设置”。
2. 打开“模型连接”的模型下载源弹窗,选择并确认保存。
3. 取消。
用户切换来源后需要重新点击下载。不能在设置保存后自动恢复上一失败任务。
@@ -810,7 +820,7 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源
| 设置 | 位置 | 作用 |
| --- | --- | --- |
| 模型下载源 | 平台功能 → 通用设置 | GoodBuddy 托管本地模型 |
| 模型下载源 | 模型连接页头 → 模型下载源弹窗 | GoodBuddy 托管本地模型 |
| 检查更新源 | 关于与更新 | GoodBuddy 版本检查和下载页 |
切换任一设置不得修改另一个设置。用户选择 ModelScope 不表示应用更新使用镜像节点;选择
@@ -850,9 +860,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
| 场景 | 行为 |
| --- | --- |
| 读取设置失败 | 显示平台功能阻塞错误,不猜测或写入来源 |
| 保存来源失败 | 保留上一个确认值,显示就地错误 |
| 当前来源无完整模型包 | 下载按钮禁用,显示前往通用设置 |
| 读取设置失败 | 页头显示读取失败,弹窗提供就地重试并禁用确认,不猜测或写入来源 |
| 保存来源失败 | 已保存来源不变,保留弹窗和草稿,显示就地错误并允许确认重试 |
| 当前来源无完整模型包 | 下载按钮禁用,提供直接打开模型连接页下载源弹窗的入口 |
| 当前来源网络不可用 | 当前下载失败或允许同来源重试,不请求另一来源 |
| 当前来源返回 404 | 显示来源缺少固定文件,不请求另一来源 |
| 重定向 Host 不受信任 | 立即失败并删除本次暂存 |
@@ -880,15 +890,19 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
- 更高未知版本拒绝,不覆盖文件。
- 并发设置更新保持完整 JSON。
### 17.2 平台功能界面
### 17.2 设置界面
- 默认选中“通用设置”Tab。
- Tab 使用 `tablist`、`tab`、`tabpanel` 和 `aria-selected`。
- 方向键、Home、End 和焦点恢复正确。
- 切换平台功能页签不改变任何设置,全局设置不显示魔法笔记入口。
- 模型连接页头的当前来源与次按钮位于模型类型页签同一行右侧、保存操作下方;通用设置不再显示下载源控件。
- Radio Group 具有持久 Label。
- 选择来源时保存一次,保存期间防止重复提交。
- 保存失败恢复原值并保留可重试错误。
- 选择 Radio 只更新草稿,确认后保存一次;未改变来源时确认不写入。
- 取消、关闭、Escape 和遮罩关闭不改变已保存来源;保存期间禁止关闭及重复提交。
- 保存失败保留草稿并允许确认重试;读取失败提供重试且禁用确认。
- 弹窗使用 body Portal、焦点循环和关闭后焦点恢复。
- 向量、语音和 OCR 的更换下载源入口直接打开模型连接页弹窗,跨分类时保留离开保护。
- 窄窗口不隐藏页签或来源名称。
### 17.3 目录
@@ -939,7 +953,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
- 新增 `ModelDownloadSource` Schema 和默认值。
- 将应用设置版本升级并迁移旧版本。
- 在“平台功能”中增加 `PageTabs`。
- 增加“通用设置”与模型下载源 Radio Group。
- “通用设置”保留平台行为;模型下载源已移至模型连接页头,通过弹窗 Radio 草稿和确认保存。
- 魔法笔记卡已迁入应用共同设置,全局页签已移除,见第 8 节。
### 阶段 2:共享目录和来源解析(已完成)
@@ -971,7 +985,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
- “平台功能”显示“通用设置”和“远程项目”两个可访问页签。
- 默认打开“通用设置”。
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
- “模型连接”页头在模型类型页签同行右侧、保存操作下方显示当前来源及“模型下载源”次按钮。
- 下载源弹窗提供 ModelScope 和 Hugging Face,默认 ModelScope;Radio 只编辑草稿,确认才保存,取消不改变已保存来源。
- 读取与保存失败均可就地重试;向量、语音和 OCR 的更换下载源入口直接打开该弹窗。
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
- 切换来源不会下载、删除、切换或重新验证任何已安装模型。
- 当前来源没有模型时,在点击下载前即可看到不可用原因。
@@ -0,0 +1,70 @@
# Desktop 0.15.6 发布准备
日期:2026-10-01。用户授权提交当前未提交改动并继续双远端推送与发布,
如 Agent 实际受影响可升补丁版本;另明确本轮仅标准六平台,不含 LoongArch。
## 候选范围
基线 `v0.15.5` 为 `3be55348a215f4343326b1950a69529247cbae6a`,
六个新增提交截止 `9cc5dc124464a67ed93014c53cbc93f96764caa2`。
初始未提交改动仅 `application-center.css` 一行选择器:
侧栏底部仅对第二个直接子元素文字 span 禁止伸缩,避免影响其他 span。
已核对并纳入本轮,未包含凭据、日志或临时产物。
新增内容包括磨砂外观、输入/缓存会话切换渲染优化、直接图片预览、设置与笔记布局、
本地 MCP 恢复及不确定名称退役、诊断和执行统计索引。
双语说明和功能矩阵按实现更新,不将已有开发记录中的全量失败记为成功。
Agent 保持 0.15.1:相对其发布标签,daemon 与锁文件无变化。
本地 OpenCode 的 HTTP 注册恢复属于 `OpenCodeRuntime`,远端使用
`RuntimeAcpBackend`/`AgentImageToolMcp`,不经过这条 HTTP 注册路径。
没有据此声称远程恢复得到同样改进,也没有新发布 Agent 包。
## 数据与验证
已读数据库迁移指南。schema 52 在事务内增加任务事件部分索引,不转换消息正文,
不执行 VACUUM;建立索引的耗时受历史规模影响,本轮未测真实大库升级。
升级前关闭应用并备份数据库和相邻 `notes/`,降级需要完整升级前备份。
- 当前源码 17 个定向文件、886 项通过,包含真实 OpenCode + HTTP 网关合成服务的
MCP 故障恢复和晚到初始化回归,数据库/统计、诊断、设置、App、图片附件和真实
Electron 磨砂、笔记布局、工作栏设置布局。
- `npm run typecheck`、`npm run lint` 通过;本轮新增付费模型调用 0 次。
- 完整测试和生产构建等待精确候选 CI,不运行本地生产构建、打包或安装包探针。
## 首轮 CI
候选 `cc451f6` 已含用户未提交样式修正并同步两个远端。Agent `36743915140`
通过,Desktop `36743915207` 为 5573 通过、57 跳过、1 失败,生产构建未执行。
磨砂测试切换主题后立即读取样式,窗口按钮 color 正在过渡,导致深色关闭态与
先前基线不同。测试改为等待当前 CSS Transition 完成,保留完整样式、几何、
拖拽、对比度及原生交互断言,不修改产品外观或全局超时。
修正后完整磨砂 Electron 回归、类型检查及 lint 通过;双语说明与产品源码不变。
`8201c9c` 的 CI `36746918608` 中磨砂回归通过,唯一失败为 161 条历史的多次
热切换 App 用例超过默认 5 秒(5573 通过、57 跳过)。只将该用例预算设为 15 秒,
保留消息数量、DOM 复用、零额外渲染、草稿、附件、展开状态和滚动位置的全部断言;
不修改全局超时或将运行时性能要求替换为等待通过。
调整后完整 App 319 项、类型检查及 lint 通过。
## 再次扩展候选
用户要求将当前未提交改动一起提交。新增 `0a02a17`(新建笔记入口)和
`a3b41bf`(设备共享元数据),并纳入五份应用导航文档修改。
设备共享需另行运行 ShareServer,首期面向本地未认证服务,仅注册设备及管理元数据;
不执行远程能力,不传输或检索知识正文,设备 ID 不构成所有权认证。
Agent 实现和锁不变,仍不升版。
扩展源码类型检查和 lint 已通过。用户随后明确要求停止本地测试、直接推送等 CI,
进行中的定向测试已中断,不计为通过,也不沿用旧候选的全量结果。
最终发布取决于包含新增提交和文档的精确候选 CI 与生产构建。
## 扩展候选焦点回归
`2c5300d` 的 Desktop CI `36753498640`:5580 通过、58 跳过、1 失败,
生产构建未执行;Agent CI `36753498643` 通过。
笔记导航用例在打开并取消带 `autoFocus` 标题输入框的新建表单后,
才断言此前打开详情时的返回按钮焦点,混合了两个不同交互阶段。
将原焦点断言移至打开新建表单之前,并断言新建标题输入框取得焦点;
保留返回列表、滚动位置、搜索内容和原卡片焦点断言,不改产品代码或延长超时。
按用户要求不运行本地测试,由新候选 CI 验证。
@@ -0,0 +1,53 @@
# Desktop 0.15.7 发布准备
日期:2026-10-01。基线为 `v0.15.6`,源码候选 `c8f327f`。
检查时工作区干净,用户要求纳入的先前改动已由该提交包含,共 75 文件。
本轮 Desktop 升至 0.15.7,Agent 保持 0.15.1,仅标准六平台,不含 LoongArch。
## 范围
- 流式 Markdown 分段复用、会话行与派生值缓存。默认 16ms 节奏不变。
- 运行记录进入时读取、可见时 15 秒刷新及页头手动刷新,不是逐事件实时视图。
- 本地自定义 MCP 发现与动态目录故障按服务隔离,取消和撤销仍生效。
- 模型连接中的受管模型下载源弹窗,确认保存、取消不变及相关入口统一。
- 设备共享默认关闭的测试入口,显式启用,关闭不撤销服务端记录。
网关修改只涉及本地自定义 MCP 发现方法;远程 Agent 的独立 MCP 实现与
Story Graph 绑定不经过这些方法,Agent 源码、锁及包未变。边界与开发验证见
[工作栏进度](../features/assistant-workbar/progress.md)。
数据库仍为 schema 52,无新增迁移。
## 证据与发布门槛
本轮遵照用户要求,不运行本地测试、类型检查、lint 或生产构建,由新候选 CI 验证。
已有开发会话记录不等于本候选全仓通过;网关会话的历史全仓失败也不掩盖。
流式输入测量见 [组件测量记录](../../tests/support/streaming-input-results.md),
不据此声称完整 App 或所有机器的相同比例提速。本轮外部模型调用为 0。
中英文 FEATURES 已核对设备共享默认值、下载源位置及实现边界,并补充本轮性能和活动行为。
发布说明保留 schema 52 备份提醒,不修改历史已发布条目。
双远端推送精确候选后,必须通过主分支全量验证与生产构建,并确认候选及双语说明后,
才能创建不可变 `v0.15.7` 标签。
## 首轮 CI
`227507d` 的 Desktop CI `36872224138`:5613 通过、59 跳过、2 失败,
生产构建未执行;Agent CI `36872224140` 通过。
英文设置布局 fixture 在 640×420 窗口将控件居中到包含固定页头的整个滚动区,
点击检测被较高的英文页头遮挡。测试共用滚动辅助函数改为扣除实际页头高度的
可见区域,仍检查命中、溢出、文字宽度和原生键盘交互,不改产品布局。
旧 outbox fixture 先建当前数据库,再降为 schema 23 并执行完整迁移,
超过默认 5 秒;仅该用例设 15 秒,保留终态、尝试次数及附件清理断言。
修复不修改发布说明或运行时代码,按用户要求继续交新候选 CI 验证。
## 第二轮 CI
`7cb74cc` 的 CI `36874700588`:首轮两项失败已通过,但仍有 5613 通过、
59 跳过、2 失败,生产构建未执行。检查提交链确认先前提交期间并行加入了
`5e77118`,移除文字输入框占位提示的问候语,App 单元测试已同步为两行,
Electron 侧栏探针却仍要求三行,导致断言失败。探针同步为两行并检查首行键盘提示,
保留平台快捷键、输入、清空、焦点、布局和搜索检查。
Markdown 用例逐一比较超过 1500 个流式前缀,超过默认 5 秒;
仅该用例改为 15 秒,保留全部 DOM 等价、前缀数及多段覆盖断言。
用户明确授权连续修复、双远端推送和 CI 通过后的标签发布,不再中途确认。
本轮仍不运行本地测试,以上修复须由下一次精确候选 CI 验证。
@@ -0,0 +1,57 @@
# Desktop 0.15.8 / Agent 0.15.2 发布准备
日期:2026-10-02。Desktop 基线 `v0.15.7`,Agent 基线 `agent-v0.15.1`;
已提交源码固定为 `b1129cedec5e4890d82fcddc186b13467f466a6f`。
仅纳入四个已提交功能修正及本次必要发布准备,不提交已有 Story Graph、
会话监督设计或宣传文章等未提交文件。
## 范围
- `dd29f30`:保存会话图片到本机或 Host、设置中列出图像工具。
- `f4d8fe7`:新记录取消、编辑焦点返回、笔记正文使用可用宽度。
- `2e5c348`:缺失内置浏览器设置时默认启用,保留已保存配置。
- `b1129ce`:缺失磨砂外观字段时默认开启,保留已保存配置。
- 数据库仍为 schema 52;Desktop 标准六平台,无 LoongArch。
- Agent 保存路径涉及守护进程、工具声明与桌面图像二进制回复,升至 0.15.2,
最低 Desktop 0.15.8;协议主版本及 Runtime 锁版本不变。
## 当前源码共享 Host 验证
共享 Linux x64 Host 的现有 SSH 固定身份可达。在独立 `/root/tmp` 目录运行
当前源码 AgentImageToolMcp,通过真实 SSH 传递工具消息,与 Desktop 的
MainImageToolSession、ImageGenerationService、真实 SQLite 会话协作。
图像为本轮生成的合法随机 PNG,196992 字节,超过单个 128 KiB 分块。
保存后的 Host 文件 SHA-256 与 Desktop 原始内容相同;默认覆盖拒绝、明确覆盖成功、
其他会话图片 ID 拒绝、Ask 写入拒绝且无文件落盘。共 8 个回复帧,
最大 174892 字节,低于 256 KiB 上限。外部模型调用 0 次。
这是生产保存服务与 Agent MCP 的真实 Host 工具层验证,不冒充完整 App、
模型自动选工具、安装包或三个 Host 架构验证。使用 SSH 流作为该探针传输;
未改变用户工作区、产品配置或 Host 已安装 Agent。
临时探针最初因打包 Zod 初始化顺序、混入仅 Desktop 依赖而无法启动,
修正探针依赖边界后验证通过;这些失败不计为产品模型调用或产品修复。
探针和包不进入仓库。用户要求不跑本地测试套件,精确候选由 CI
执行全量验证和生产构建后,才允许打 Desktop 和 Agent 不可变标签。
## 发布核对
中英文 FEATURES、Desktop 与 Agent 双语说明必须与上述实现边界一致。
只暂存必要发布文件,保留工作区用户文件;双远端核验候选和标签一致。
公开验证包括六平台 Desktop、三平台 Agent 签名目录、GitHub/OSS 同步及 Latest 分离。
## 首轮 CI 修正
`20317c2`:Agent CI `36960575058` 成功;Desktop `36960575157` 为
5620 通过、59 跳过、4 失败,构建未执行。
Obsidian 专项 IPC fixture 未安装浏览器服务,显式关闭无关浏览器能力;
MCP 列表断言纳入新增图像卡片的数量和名称;覆盖层原生点击先将控件居中,
避免磨砂顶栏覆盖 `nearest` 保留在顶部的控件,并补充命中遮挡诊断。
保留所有业务、覆盖层和原生交互断言,不改变产品默认值、关闭沙箱或跳过测试。
这些修正由新候选 CI 验证。
`5368e0e` 的 CI `36962139369` 中上述四项通过,余下新记录取消焦点用例失败,
5623 通过、59 跳过、1 失败。测试用 `fireEvent.click` 点击取消不会像原生点击
那样移动焦点,确认框记住的仍是先前的返回总览按钮。用例先等打开详情的焦点完成,
明确聚焦取消按钮后再点击,并等继续编辑实际聚焦编辑器后进行下一步;
保留放弃草稿后回到记录与未保存新记录的断言,不改产品焦点规则或延长超时。
@@ -1,9 +1,13 @@
# 应用导航与系统工具入口
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,五个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
商店入口、推荐位或安装流程。
设备共享作为第五项追加到原有应用顺序,测试阶段默认关闭,启用后可从菜单和管理 Modal 打开,不提供常驻
开关;服务地址与设备名称在其页面保存。首期仅支持本地服务的设备注册与元数据目录,详见
[设备共享](../device-sharing/README.md)。
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
本轮 A–D 已接入生产源码:应用菜单、应用管理 Modal、共享应用设置、持久化偏好及本机推理监控。菜单不按打开历史或常驻状态筛选,无模态遮罩;点击应用行关闭菜单,知识库、智能心跳和魔法笔记打开主内容区页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。管理列表、菜单和侧栏按同一保存顺序排列,侧栏按启用和常驻过滤。知识库在管理 Modal 中提供“打开”和排序,不提供启用、常驻开关或通用空设置;原工作区配置及心跳单条计划 enabled 保持原义。验证范围及集成缺口见[实施进度](./progress.md)。
@@ -25,7 +29,7 @@
待实现的应用扩展见 [FR-17](./prd.md#fr-17-主机管理与云环境管理待实现):
主机管理从设置中心迁入独立工作页面,云环境管理作为同级应用;业务定义由
[远程主机功能文档](../remote-host/README.md)维护。上文四应用描述仍为当前实现。
[远程主机功能文档](../remote-host/README.md)维护。该计划不包含已接入的设备共享元数据页面。
现有 `heartbeat` 入口承载监督者工作回顾与故事线图谱,可管理启用、常驻和排序。应用默认关闭;完整执行边界见[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)。未来市场复用同一应用身份,不增加平级重复入口,目标定义见[监督者产品设计](../conversation-supervision/supervisor-prd.md)。
@@ -16,7 +16,7 @@
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
3. 文件浏览器、终端、浏览器和资源监控始终是系统工具,不能卸载或被插件替换。
4. 左下角提供应用中心和独立固定设置入口;文件、终端、浏览器、资源监控只通过右侧工作栏 Tab 和“+”访问,不进入应用中心。
5. 四个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。取消常驻不关闭功能,关闭不删除数据或导航偏好。
5. 五个内置应用共享保存顺序。知识库始终启用并常驻,位置可调整;智能心跳、魔法笔记、本机推理监控可独立设置启用和常驻。设备共享在测试阶段默认关闭,提供启用开关,启用后可从应用菜单打开,不常驻侧栏。关闭设备共享时退出其页面,不清除目录或撤销已发布记录。取消常驻不关闭功能,关闭不删除数据或导航偏好。
6. 入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
7. 应用中心始终可达,点击向上展开无模态遮罩的锚定菜单,只列已启用应用,不使用打开历史或常驻过滤。菜单底部“管理应用”打开既有 Modal,关闭的可选应用在其中仍可搜索、配置和重新开启。
8. 所有入口和新请求的功能判断读取同一份保存成功的权威设置;保存中的目标值不提前生效。
@@ -25,6 +25,7 @@
```ts
type NavigationContribution =
| { type: 'application'; id: 'device-sharing'; source: 'builtin'; enabled: true; pinned: false }
| {
type: 'system-entry'
id: 'chat' | 'activity' | 'application-center' | 'settings'
@@ -46,7 +46,7 @@
- 用户从侧栏底部进入应用中心,查找、打开和配置内置应用。
- 主导航承载应用入口,本机推理监控打开 Modal,其余内置应用打开完整页面;辅助工具由工作栏承载。
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
- 四个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,未常驻的已启用应用仍可从应用菜单打开。
- 五个内置应用均支持排序;智能心跳、魔法笔记和本机推理监控可独立设置启用与常驻,知识库始终显示,设备共享在测试阶段默认关闭,可启用但不常驻侧栏。未常驻的已启用应用仍可从应用菜单打开。
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
## 4. 非目标
@@ -75,7 +75,7 @@
### FR-3 内置应用区
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。四个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
主导航在当前对话之后按共享保存顺序显示应用,知识库始终显示,可选应用仅显示已启用且常驻项;运行记录位于应用区之后。五个应用均可排序。本机推理监控入口打开独立 Modal,其余应用打开对应一级页面,保留
现有徽标、范围和页面状态。管理 Modal 列出全部内置应用,知识库只提供“打开”和排序。
### FR-4 隐藏应用
@@ -127,13 +127,13 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
### FR-11 应用导航设置
管理 Modal 的四个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
管理 Modal 的五个应用行均提供上下移动和拖动排序,不受启用或常驻状态限制;管理列表、菜单和侧栏共用一份顺序。箭头边界按完整顺序计算,搜索过滤不丢失隐藏项。智能心跳、魔法笔记和本机推理监控另提供应用设置、常驻管理;知识库提供“打开”和排序,不添加通用空设置页或开关。设备共享提供启用设置、打开和排序,不提供常驻。原工作区配置入口及心跳单条计划 enabled 保持不变。设置中“启用应用”与“常驻左侧菜单”分别保存,
修改即时提交;请求期间串行处理并禁用会修改同一配置的控件,失败就地反馈。
魔法笔记设置统一从应用中心对应行进入,笔记页与全局设置不再提供重复入口。持久化成功后的设置变更同步覆盖工具配置写入;每次重开中心先刷新并锁定修改,较新的确认快照优先于迟到的读取结果。
### FR-12 偏好持久化
四个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控。心跳应用默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻;
五个应用的顺序及可选应用启用、常驻按设备持久化,重启后恢复。默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享。心跳和测试阶段的设备共享默认关闭,其余应用默认启用;心跳和魔法笔记默认常驻,本机推理监控默认不常驻,设备共享不提供常驻开关;
魔法笔记待办数量默认显示,评论方式与形式沿用现有默认值。迁移保留已保存的启用、常驻和排序选择,
旧魔法笔记入口开关绑定业务启用值,迁为“启用应用”并保留原值;常驻使用独立配置,
只有已发布的独立显隐偏好才映射到常驻,不能由常驻推导业务启停。旧可选应用顺序补入知识库、智能心跳默认位置并保留已有相对顺序,具体补齐规则见[技术设计](./technical-design.md#43-持久化与-ipc)。`heartbeatEnabled` 的缺失值与保存值按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)处理;不读取未发布草案中的 `knowledgeEnabled`。
@@ -173,7 +173,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
- 主导航能明确区分固定系统入口和可配置内置应用。
- 运行记录在所有应用显隐组合下保持可见并可打开。
- 左下角不再用“本地工作区”与系统架构占据主要空间。
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。四个应用均可排序,知识库始终可用,三个可选应用可管理启用与常驻,首期无商店入口。
- 应用菜单只列已启用应用,点击行关闭菜单;本机推理监控打开保留底层工作区的 Modal,其余应用进入主内容区。“管理应用”打开可搜索的既有 Modal。五个应用均可排序,知识库始终可用,四个可选应用可管理启用,其中设备共享不提供常驻,首期无商店入口。
- 系统工具仅从右侧 Tab 与“+”访问;选择 Tab 不创建或改绑实例,“+”可显式创建额外浏览器或终端。
- 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、
最小化后停止请求新样本。
@@ -188,7 +188,7 @@ Renderer 内存中最多保留最近 5 分钟。任一条件不满足时停止
- 侧栏最底部应用中心打开向上锚定的轻量菜单,无模态遮罩;底部“管理应用”进入与设置相同的整窗 Modal 和可搜索应用列表,设置按钮保持独立。
- 应用中心提供魔法笔记、知识库、智能心跳及本轮追加的本机推理监控入口,不要求安装。本机推理监控 Modal 与服务单独实现和验证。
- 四个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
- 五个应用共享排序;支持智能心跳、魔法笔记、本机推理监控常驻主导航及取消常驻,知识库始终显示。设备共享仅从应用菜单和管理 Modal 进入,业务定义见[设备共享](../device-sharing/README.md)。已启用未常驻应用仍可从菜单打开,取消常驻不删除数据或停止后台任务。
- 通用应用设置统一从应用中心对应行进入;应用页面保留实际业务配置,本机推理监控保留“模型与连接设置”。
魔法笔记优先接入待办数量、AI 评论方式和评论形式,入口显示与常驻控制统一,避免重复开关。
- 文件、终端、浏览器保留在右侧工作栏 Tab,不在左下角复制入口,也不纳入应用中心。
@@ -274,4 +274,4 @@ Modal 只有服务列表,显示名称、简短状态、引擎、模型、资
导航扩展须保留已有应用排序、常驻偏好和远程能力启用值;取消主机常驻不影响云环境使用
主机能力。两个应用的能力依赖、迁移范围及产品验收由
[主机与云环境 PRD](../remote-host/prd.md)和[功能逻辑](../remote-host/logic-design.md)定义,
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文四应用的当前状态描述。
业务页面布局见[界面设计](../remote-host/ui-design.md)。该项尚未实现,不改变上文五应用的当前状态描述。
@@ -1,5 +1,14 @@
# 应用导航实施进度
## 2026-10-01:设备共享入口
新增 `device-sharing`,启用后应用菜单及管理 Modal 均进入主内容区页面;管理行可排序。
首期初始实现没有启用开关,同日后续改为测试阶段默认关闭、可手动启用,不提供常驻开关。
旧顺序通过现有存储归一化追加新项,保留原设置;权威写入要求五项完整顺序。
ApplicationCenter、ApplicationMenu、设置迁移以及 App 入口/排序回归已通过,真实 Electron
从应用中心进入页面并完成后端流程。精确命令、完整范围及全仓测试记录见
[设备共享实施进度](../device-sharing/progress.md)。下方历史四应用记录保留原验证时的范围。
## 2026-09-28: Composer shortcut initialization follow-up
The composer now retains successful shortcut-settings snapshots independently of
@@ -22,6 +22,7 @@ type WorkspaceView =
| 'knowledge'
| 'heartbeat'
| 'local-inference'
| 'device-sharing'
| 'activity'
| 'settings'
```
@@ -31,7 +32,7 @@ type WorkspaceView =
实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控
面板。侧栏底部已替换为应用中心与独立设置按钮。
应用中心、四应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
应用中心、五应用共享排序、可选应用常驻及启用联动、共同设置页和本机推理监控已接入生产源码;本机资源监控的
Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏已有 Tab 和“+”链路。
行为由[功能逻辑](./logic-design.md)定义,验证范围见[实施进度](./progress.md)。
@@ -61,7 +62,7 @@ Main/Preload/Renderer 实现仍待交付。系统工具只使用右侧工作栏
### 3.1 内置应用清单
```ts
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference'
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat' | 'local-inference' | 'device-sharing'
type BuiltInApplicationDefinition = {
id: BuiltInApplicationId
@@ -81,7 +82,7 @@ type BuiltInApplicationDefinition = {
应用中心调用既有 `navigateFromSidebar` 或同一页面导航函数,沿用未保存离开检查和焦点
策略。打开前检查确认的启用值;未常驻但已启用的应用不附带常驻写入。本机推理监控从常驻入口、菜单及管理 Modal 均经 `setView('local-inference')` 打开独立 Modal,保留底层工作区与设置草稿;其他应用继续页面导航。
`ApplicationMenu` 按四个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。
`ApplicationMenu` 按五个应用共用的持久化顺序派生清单,仅按启用过滤,不读取常驻或打开历史。应用行先关闭菜单再导航;底部“管理应用”关闭菜单、恢复锚点焦点并打开既有 `ApplicationCenter` Modal。设备共享使用默认 `false` 的 `deviceSharingEnabled`,启用后进入主内容区页面,无常驻字段;迁移与关闭规则见[设备共享技术设计](../device-sharing/technical-design.md#当前首期实现)。
应用中心对应行的设置操作仅接受 `EditableApplicationId`;应用页和全局设置不提供重复入口。知识库的管理行提供“打开”和排序,原工作区实际配置保留,不添加通用空设置页。
共同页复用设置 Modal 容器、配置读取及更新方法,不建立独立配置存储或重复表单。
@@ -124,9 +125,9 @@ type ApplicationNavigationSettings = {
}
```
`applicationNavigation` 保存四个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`。
权威返回 schema 和更新 schema 均要求四个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
`applicationNavigation` 保存五个应用的完整顺序和三个可选应用的常驻布尔值。默认顺序为
`knowledge`、`heartbeat`、`magic-notes`、`local-inference`、`device-sharing`;心跳和魔法笔记常驻默认 `true`,本机推理监控默认 `false`,设备共享无常驻开关。
权威返回 schema 和更新 schema 均要求五个 ID 各出现一次,拒绝重复、未知和缺失 ID;`pinned` 包含三个可选应用,缺失 `heartbeat` 时补 `true`,显式布尔值保留。Renderer 直接读取该顺序,再按各入口的可见性过滤。知识库始终启用、常驻,无可写开关键。
按[排序规则](./logic-design.md#3-主导航组合规则)提交完整顺序;不持久化可推导的可见 ID 列表。
首期不预建插件字段,也不为未发布的旧隐藏 ID 草案增加迁移读取器。
@@ -166,7 +167,7 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
`true`、`false`、单选值和无关设置,不迁移或清理笔记、知识及会话业务数据。
新安装和升级时缺失整个 `applicationNavigation` 均使用共享默认值,本机推理监控启用但不常驻;本次不增加存储版本。已保存的常驻值均保留,无法区分旧默认 `true` 与用户主动固定,不强制改为 `false`。
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失的可选应用。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes]`;完整四项顺序原样保留。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
仅存储读取允许补齐旧数据:缺失的 `knowledge`、`heartbeat` 按此顺序前置,再保留已有 ID 的相对顺序,最后按默认顺序追加缺失项。旧 `[local-inference, magic-notes]` 因此变为 `[knowledge, heartbeat, local-inference, magic-notes, device-sharing]`;旧完整四项顺序保留并在末尾追加 `device-sharing`。缺失或空顺序采用默认顺序,部分 `pinned` 映射仅补缺失键,显式 `false` 保留。重复或未知 ID 仍按现有损坏文件处理规则处理,不静默去重或丢弃。版本 11 迁移时写入归一化结果;版本 12 读取时归一化,下一次设置保存时写入完整结果。IPC 和配置工具写入不接受这些部分数据。
版本 12 属于未发布的分支格式,原草案 `knowledgeEnabled` 已移除,不增加兼容读取器。`heartbeatEnabled` 是当前应用启用字段,由 Store 归一化缺失值并保留显式保存值。
@@ -352,7 +353,7 @@ resourceMonitor.getLocalSnapshot(input: {
### 10.2 Main 与契约测试
- 顺序包含四个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
- 顺序包含五个应用 ID 各一次;公开写入拒绝重复、缺失、未知 ID 及无效常驻字段;旧存储顺序按迁移规则补齐,不为未发布的 `knowledgeEnabled` 添加迁移。
- 未配置的心跳应用默认关闭,其余应用启用及笔记布尔项默认开启,本机推理监控默认不常驻;已保存的启用、常驻值和评论单选值经迁移及重启保留。
- 更新常驻或顺序不修改启用、计划或业务数据;笔记既有配置键保持语义。
- 串行 patch 在 Main 最新快照上合并,原子写入失败不发布目标状态;响应丢失后重读实际结果。
@@ -85,13 +85,17 @@
```
取消常驻或关闭可选应用后不留下空白。知识库始终启用并显示;心跳按启用和常驻显示,位置遵守共享应用顺序。运行记录始终位于应用区之后。
四个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
五个应用均在管理行上通过拖动及“上移”“下移”按钮排序;首尾无目标时禁用对应按钮。两种方式提交完整应用顺序,保存成功后更新,重启保留。当前对话、运行记录和底部系统入口不参与排序。
取消常驻不关闭当前页面或本机推理监控 Modal。重新常驻按已保存顺序恢复,关闭应用保留常驻与顺序偏好。
主侧栏始终读取最后确认配置,不展示尚未保存成功的排序或开关目标值。
## 4. 应用中心与共同设置
设备共享追加到默认顺序末尾,测试阶段默认关闭。管理行显示“可选”及启用状态,提供打开、
设置与排序按钮;设置内提供启用 Switch,不提供常驻开关。关闭时禁用“打开”,应用菜单只在
启用时显示该项,点击后在主内容区打开[设备共享页面](../device-sharing/ui-design.md#当前首期页面)。
底部点击打开轻量菜单,锚定按钮向上展开,无模态遮罩、背景隔离或焦点循环。菜单按共享保存顺序列出已启用应用,不按打开历史或常驻状态过滤。点击应用行关闭菜单,本机推理监控打开独立 Modal,知识库、智能心跳和魔法笔记在主内容区打开页面。分隔线下的“管理应用”关闭菜单并打开既有管理 Modal。
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
@@ -47,7 +47,7 @@
## 3. Epic B:管理应用入口
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;四个应用均可排序,知识库始终启用并常驻。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
本节的启用、常驻和共同设置适用于智能心跳、魔法笔记、本机推理监控;五个应用均可排序,知识库始终启用并常驻。设备共享在测试阶段默认关闭,提供启用开关,不提供常驻开关;启用后可从应用菜单和管理 Modal 打开,关闭后退出当前页面并保留目录及发布记录,旧设置缺失时默认关闭,明确启用值在重启后保留。业务验收见[设备共享首期需求](../device-sharing/prd.md#当前首期本地元数据)。心跳的默认关闭及执行边界按[应用启停与执行](../conversation-supervision/logic-design.md#应用启停与执行)验收,保留既有故事 ID。
### US-B1 识别完整应用
@@ -139,14 +139,14 @@
验收:
- Given 首次使用且未配置导航偏好
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;侧栏只按启用与常驻过滤可选项
- Then 默认顺序为知识库、智能心跳、魔法笔记、本机推理监控、设备共享;心跳默认关闭且保留默认常驻偏好,魔法笔记默认常驻,本机推理监控默认启用但不常驻,仍可从应用菜单打开;设备共享默认关闭,启用后只在应用菜单和管理 Modal 提供入口;侧栏只按启用与常驻过滤可选项
- When 用户修改常驻,或通过拖动、键盘可操作的上移和下移按钮排序并保存成功
- Then 管理列表从上到下、菜单和侧栏按同一保存顺序显示,知识库和智能心跳可移动;当前对话、运行记录及底部系统入口位置不变,重启后恢复保存结果
- And 每行均提供上下移动,以完整保存顺序中的相邻应用为目标;未常驻及关闭应用仍可排序,设置详情不提供排序控件
- And 搜索时箭头仍按完整顺序移动,保留未命中应用,拖动禁用;清除搜索后显示完整保存结果
- Given 旧设置仅保存本机推理监控、魔法笔记的顺序
- When 升级后读取设置
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值
- Then 知识库、智能心跳补在前面,两个可选应用保留原有相对顺序和常驻值,设备共享追加到末尾
- Given 应用已关闭或取消常驻
- When 用户重新开启或常驻
- Then 按保存顺序恢复入口;关闭或取消常驻不删除顺序,已关闭应用仍可管理常驻偏好
@@ -1,5 +1,62 @@
# 工作栏实现与验证进度
## 2026-10-01:自定义 MCP 失败中断回复
已修复共用网关将单个上游发现失败升级为整轮失败的问题,行为见
[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
正常服务的连接继续复用,失败服务不进入工具列表,动态刷新失败也按服务隔离;
取消和授权撤销继续生效。初次失败的服务在下一请求重试,原有连接超时未改。
验证使用真实 HTTP 上游和网关,覆盖部分失败、无可用工具、非法分页和超量目录、
动态刷新失败后恢复、取消及撤销。Continue 使用真实网关和模拟 Host;新增实际 OpenCode
二进制用例在自定义 MCP 返回 HTTP 503 后完成 Execute 回复,单次本地合成模型调用,
外部模型调用 0 次。网关与 Continue 定向测试 65 项通过;OpenCode 生命周期 4 项通过。
`npm run typecheck`、四个修改源码文件的 ESLint 通过。
完整 `npm test`:5,565 通过、89 跳过、4 失败。失败为 Agent 大依赖清单安装超时、
Magic Notes 采集的 `memo is not defined`,以及终态探针和手动压缩的界面等待超时。
全仓 lint 在并行修改的 `App.tsx` 性能测量代码处报告渲染纯度及不可变性错误;
本次未修改这些文件,不将完整验证记为通过。
远程 Agent 的独立 MCP 实现及 Story Graph 绑定不经过改动方法,本次无需远程 Host 验证。
尚未发布或替换已安装应用。
## 2026-10-01:本地 MCP 晚到初始化覆盖新连接
根因修复见[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
已核对随包 OpenCode 1.18.29:`MCP.add` 的初始化不随客户端取消而中止,完成后写入同名状态,
并关闭该名称下已有的连接;`disconnect` 只处理当时已登记的客户端。旧实现在 `mcp.add`
超时或取消后仍复用同一名称,前一轮的晚到初始化(授权已撤销,结果为 `failed`)会覆盖下一轮
刚建立的连接。现在响应不确定的名称作废,后续改用唯一替代名称。
验证:新增真实 OpenCode+实际网关用例,通过本地代理挂起第一次 MCP `initialize`,
使第一轮在 3 秒控制超时后结束,第二轮建立连接后再放行挂起的初始化。修复前该用例
复现为第二轮连接状态从 `connected` 变为 `failed`;修复后第二轮连接保持 `connected`
且请求完成。`opencode-runtime`、`opencode-runtime-lifecycle`、`knowledge-mcp-gateway`
三个测试文件 180 项通过;`npm run typecheck` 通过;相关文件 ESLint 通过。模型为本地合成
服务,外部模型调用 0 次。9 月 30 日那次失败的原始时序没有底层日志,未确认就是这一竞态;
本次修复的是已复现的同类失败路径。尚未发布或替换已安装应用。
## 2026-09-30:本地 MCP 连接诊断与恢复
已实现[本地 MCP 初始化失败恢复](./runtime-process-reuse-technical-design.md#41-本地-mcp-初始化失败恢复)。
常驻 HTTP 网关和请求级授权保留;内置及自定义 MCP 对明确的暂时性传输故障最多重试一次,
必须先成功清理失败连接。SDK 返回错误和抛出错误均有覆盖,未知失败、HTTP/API 拒绝和
鉴权错误不重试。界面保留脱敏错误详情,两端失败诊断通过令牌摘要关联。
网关初始化异常也会回收未完成会话。
最终定向验证:`npx vitest run` 指定 `opencode-runtime`、`opencode-runtime-lifecycle`、
`opencode-runtime-permissions`、`knowledge-mcp-gateway`、`knowledge-mcp-gateway.obsidian`、
`create-runtime`、`desktop-diagnostics`、`feedback-service` 八个测试文件,222 项全部通过。
`npm run typecheck` 和 `npm run lint` 通过。真实 OpenCode 与实际网关的恢复用例在连接成功后
注入响应丢失,验证断开先于重连、授权不变、请求完成和网关继续监听;该用例每轮产生一次
本地合成模型请求,外部模型调用为 0。另有真实 HTTP 初始化连续失败后的会话回收测试。
全量 `npm test` 在 600 秒后超时,未取得汇总;超时前报告 `SettingsPanel.test.tsx` 中
中英文磨砂玻璃开关两个用例失败。这些文件存在并行修改,未判定失败归因,全量验证不记为通过。
本次未发布或替换已安装应用。远程 Agent 的 ACP 和 Agent MCP 连接路径未修改,
不将本地测试结果作为远程恢复验证。
## 2026-09-30:OpenCode 终端启动准备
原先每次“在终端中打开” OpenCode 都会把随包插件依赖目录(约 3,600 个文件、52 MB)
@@ -177,6 +177,38 @@ SelectedRuntimeManager
健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server
无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。
### 4.1 本地 MCP 初始化失败恢复
桌面 HTTP 工具网关保持监听,MCP 注册和授权仍按请求创建、清理。本地 OpenCode 的内置
工具和自定义 MCP 共用连接逻辑:仅对明确的连接拒绝、连接重置、断管或传输超时,在
一秒内成功断开失败连接后重试一次。SDK 返回的无 HTTP response 网络错误和抛出的网络
错误采用同一判定;HTTP/API 错误、鉴权或协议错误、未知失败不自动重试。
取消立即终止后续尝试。Prompt 和工具调用不重放。
自定义 MCP 上游发现由 `KnowledgeMcpGateway.getCustomMcpBindings` 按服务隔离失败。
某个服务连接、列举或校验工具失败时,记录脱敏诊断并跳过该服务,保留正常服务的连接;
全部失败时返回空的自定义工具列表,Continue 和内置 OpenCode 仍可开始回复。
首次发现的结果在当前授权内复用,初次失败的服务在下一次请求创建授权时重试。
动态刷新失败会清空该服务的工具列表,下次发现时再刷新,不阻断其他服务。
请求取消和授权撤销仍终止操作,工具总数和名称冲突检查保留。发现仍等待连接尝试结束,
因此连接超时可能延迟首个回复;失败隔离不等于后台加载。
OpenCode 1.18.x 的 `mcp.add` 在服务端独立执行:客户端超时或取消不会中止初始化,
`disconnect` 也只清理当时已登记的连接。晚到的初始化完成后会按名称写入状态,并关闭同名
的现有连接。因此,`mcp.add` 未得到 HTTP 响应(抛错、超时、取消或无 response 的网络错误),
或断开未确认成功时,该名称即作废;同一槽位之后改用本客户端内唯一的替代名称
(前缀加 8 位 base-36 序号,仍为 12 个字符)。槽位随请求结束释放,不因断开失败而长期占用。
已得到响应的正常失败继续复用原名称。
界面显示有界、脱敏的失败详情。`runtime.mcp.failed` 诊断记录连接或断开阶段、失败类别、
耗时、尝试次数、内置或自定义类型及可用的 HTTP 状态;通过本轮令牌的 SHA-256 摘要与
网关鉴权、初始化和工具发现失败记录关联。日志不保存令牌、地址、请求正文或原始错误。
网关初始化抛错时也清理未完成会话,防止失败会话累积。
此恢复逻辑属于本地 `OpenCodeRuntime`。远程 Agent 使用 `RuntimeAcpBackend` 和
`AgentImageToolMcp`,不经过本地 MCP 注册或网关 HTTP 处理;本节不代表远程恢复能力。
远程 Story Graph 通过网关的独立绑定调用,不经过上述自定义 MCP 发现方法。
## 5. DeepSeek Harness
### 5.1 共享 Host 的最小改法
+2
View File
@@ -2,6 +2,8 @@
电脑控制包含 GoodBuddy 对浏览器和客户端桌面能力的产品实现与平台适配状态。
内置浏览器 MCP 默认开启,用户可在设置中关闭。已保存的开关选择在重启和升级后保留。
## 文档导航
| 文档 | 权威职责 |
+11 -5
View File
@@ -1,7 +1,12 @@
# 设备发现与共享
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
设备共享在测试阶段默认关闭,先在“管理应用 → 设备共享 → 应用设置”启用,再从“应用 →
设备共享”或管理列表的“打开”进入。当前首期连接本地未认证
ShareServer 元数据服务,默认地址为 `http://127.0.0.1:8787`,可注册本机、浏览设备与发布目录、
发布能力或知识的元数据并撤销本机 ID 的条目。设备 ID 只用于识别,不是所有权认证。
当前实现不执行远程能力,不读取、搜索或下载知识正文;来源模式和权限均为元数据声明。
局域网发现、配对、组织网关和远程调用仍是后续设计,不是首期交付内容。
## 文档导航
@@ -9,12 +14,13 @@
| --- | --- |
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
| [UI 设计](./ui-design.md) | 当前应用入口、单页表单与列表,以及后续发现、配对界面 |
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
| [实施进度](./progress.md) | 首期实现、检查命令、真实 Electron 联调证据及未测范围 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
决策表。功能尚未实施,因此不创建 `progress.md`。
当前没有单独的 User Stories:首期验收见 PRD 的 `FR-M1` 至 `FR-M5`;后续场景仍在原有
需求与逻辑决策表中保留。下列 Peer、配对、网关术语仅用于后续设计。
## 术语
+22 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
@@ -12,6 +12,27 @@
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
## 当前首期规则
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文发现、配对、授权和任务状态均为
后续设计,不适用于本地未认证元数据服务。
应用启用状态默认关闭,缺失设置按关闭处理,明确保存的启用状态在重启后保留。启用只开放
页面入口;打开页面才读取目录。关闭后菜单隐藏该项,当前页面返回对话;不清除设备设置、
目录或已发布记录,不停止已有请求。该应用开关不代替后续发现、广播和发布的独立状态。
本机 ID 在首次读取设置时生成并原子保存;并发首次读取共用一次加载。名称与服务 URL 更新
串行保存,不接受 Renderer 写入设备 ID。已存在设置无法读取或解析时直接报错,避免静默
更换身份。切换服务保持本机 ID,但目标服务必须有本机注册记录才能发布。
页面初次加载读取真实目录,空列表、加载中和读取失败分别展示。刷新失败保留旧列表并标记
可能过期,禁用发布和撤销;用户可修改配置或重试。每次请求固定使用开始时读取的服务地址。
页面操作期间锁定重复提交。保存失败保留设置草稿,发布失败保留条目草稿。
发布状态只有 `published` 与 `revoked`。撤销入口匹配本机 ID,Main 再查询本机发布列表并确认
条目 ID;这只是防止误操作,不是认证。撤销只改变元数据状态。能力类型发送三个 `false`
权限;知识类型按独立选择发送。目录读取无需注册,注册记录不表示在线。
## 1. 核心不变量
### INV-1 连接方式独立
+27 -1
View File
@@ -4,13 +4,39 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy Desktop |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
## 当前首期:本地元数据
2026-10-01 首期范围由 `FR-M1` 至 `FR-M5` 定义。后文 `FR-1` 至 `FR-14` 保留为后续共享网络
设计,不能用来推断首期已有发现、认证、调用或内容传输能力。
- **FR-M1 入口与设置**:设备共享为可选应用,测试阶段默认关闭,应用中心提供启用开关;
启用后才出现在应用菜单,关闭时退出当前设备共享页面。旧设置缺少开关时按关闭处理,已保存的
明确启用值保留。关闭不清除目录、设备设置或撤销已发布记录。本机设备 ID 稳定保存,用户可
修改服务 URL 和设备名称。设置保存后重启仍保留;新设备默认连接 `http://127.0.0.1:8787`。
- **FR-M2 注册与浏览**:用户显式注册本机,随后查看服务实际返回的设备及发布列表。注册
包含 ID、名称、平台、应用版本;时间来自服务记录,不从更新时间推断在线状态。
- **FR-M3 元数据发布**:注册本机后可发布名称、描述、能力或知识类型、设备或服务器来源。
知识的搜索、读取、下载三个标志独立选择。服务器来源不会上传、同步或托管内容;权限
标志不提供内容访问实现。
- **FR-M4 撤销与刷新**:只给匹配本机 ID 的已发布条目提供撤销入口,确认后更新服务记录并
刷新目录,显示已撤销状态。该筛选不构成认证,服务本身没有所有权鉴权。
- **FR-M5 失败恢复**:请求有超时,错误可见;失败保留用户输入,列表失败不伪装为空目录。
修改服务 URL 后必须先保存,再向新服务注册或发布。既有应用设置、排序和常驻偏好保留。
验收顺序为启用应用、保存配置、注册本机、浏览返回列表、发布知识元数据、检查三个独立标志、撤销、
刷新并重启读取设备 ID。网络请求由 Main 发出,Renderer 只调用显式 preload 方法。
## 后续设计
以下章节为完整共享网络计划,尚未实施。
## 1. 背景
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
+94
View File
@@ -0,0 +1,94 @@
# 设备共享实施进度
日期:2026-10-01。当前实现范围见 [README](./README.md)。
## 2026-10-01:测试阶段默认关闭
对应 FR-M1,新增持久化 `deviceSharingEnabled`,默认 `false`,旧设置缺少字段时补为关闭,
明确保存的 `true` 保留。应用中心提供启用 Switch,不增加常驻;菜单仅显示已启用项。
从应用中心或设置变更事件关闭时,App 退出设备共享页面并返回对话;设备设置、目录与已发布
记录保留。中英文文案、功能列表和导航文档已同步。
本轮验证:设置存储、配置工具契约及服务、应用中心、菜单和共享页面共 6 文件 112 项通过;
完整 `App.test.tsx` 321 项通过;设置面板、更新设置及笔记工作区 3 文件 246 项通过。
`npm run typecheck` 与 `npm run lint` 通过。首次 App 回归发现关闭逻辑误触其他设置导航,
限定为当前设备共享页后复跑通过;新增测试的查询参数类型错误也已修正。
设置存储回归覆盖版本 11/12 的字段缺失、显式开启/关闭、无关更新、事件与重读;App 回归
覆盖默认隐藏、启用、菜单打开、UI 关闭和外部事件关闭,重新启用不自动返回该页。
真实 Electron/ShareServer 用例 1 项通过,新增默认禁用“打开”、仅一个启用 Switch 的检查,
启用后继续注册、发布、撤销、刷新及浅深主题 480px 检查。该 Electron 用例的应用设置由组件
fixture 更新,持久化由真实设置存储测试覆盖,不据此声明完整生产 App 或安装包联调通过。
本轮未运行全仓测试,未改动 Agent/Runtime 路径,无模型调用。
## 首期接入记录
`a3b41bf` 已接入应用入口、Main 服务、可信 IPC、preload 与页面,
支持配置 ShareServer、注册设备、读取目录、发布/撤销元数据及本机 ID 持久化。
设备 ID 不是认证凭据,权限字段不代表已实现远程操作;正文传输、能力调用、
发现和配对仍不属于本期实现。
当前组合源码类型检查和 lint 通过。仓库包含服务、IPC、页面测试和需指定
`GOODBUDDY_SHARING_SERVER_ROOT` 的真实 ShareServer/Electron 联调用例。
本轮发布准备中的定向测试被用户中断,并要求直接推送交 CI 验证;
不据此声明测试或真实服务联调通过。发布结果以最终候选 CI 为准,
条件跳过的真实服务用例不能计入已验证能力。
## 2026-10-01:桌面实现轮次的独立验证
以下记录来自设备共享实现会话,独立于上文发布准备轮次;已实际运行,不作为发布 CI 或安装包
验收的替代。对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。
| 检查 | 结果与覆盖范围 |
| --- | --- |
| 定向 7 文件 | 114 项通过:Main HTTP 契约、稳定 ID、设置重读、可信 IPC、超时与错误、页面发布/撤销、输入保留、旧设置迁移与应用排序 |
| 完整 `App.test.tsx` | 320 项通过,含新增设备共享入口与既有设置 Modal 回归 |
| `goodbuddy-config-contracts.test.ts` | 8 项通过;修正旧四项写入样例,保留重复、未知及缺失项拒绝断言 |
| 真实 Electron/ShareServer | 1 项通过,使用相邻后端源码、独立端口和临时数据目录,真实窗口运行生产页面、preload、可信 IPC 和 Main 服务 |
| 类型与静态检查 | `npm run typecheck`、`npm run lint`、`git diff --check` 通过 |
Electron 联调从应用中心打开页面,注册本机,发布知识元数据,服务返回确认
`sourceMode=server`、`search=true/read=false/download=true`,随后撤销并刷新至
`status=revoked`。注册、发布、撤销确认和刷新使用原生鼠标输入;重读磁盘保持设备 ID。
浅深主题的 480px 视口均无横向溢出。首次窄窗口检查因系统缩放与预期 CSS 宽度不一致失败,
沿用现有 Electron 测试的固定设备缩放设置后通过。该测试是组件组合,未启动完整生产 App,
也未验证安装包、截图视觉效果或 macOS/Linux 窗口。
全仓 `npm test` 用时 760.82 秒:468 文件通过、13 文件跳过、2 文件失败;5,549 项通过、88 项
跳过、2 项失败。设备共享真实 Electron 联调在这次全仓运行中也通过。两项失败分别为:
- 配置工具样例仍提交四项顺序。运行期间修正后独立复跑 8 项通过;随后与服务测试合跑
11 项也通过,服务测试包含对尚不存在设置文件的并发首次读取。
- `inline-help.electron.test.ts` 的 `Hover Escape closed modal: dark 360x320` 断言失败。
未修改该文件或帮助组件,独立复跑 1 项通过,原因未确定;不把复跑结果写成全仓一次全绿。
完整日志位于本次工作机临时目录 `opencode/goodbuddy-sharing-full-tests.log`。没有再次执行整套
全仓测试。本功能测试没有模型调用。中文设备共享文档经 `deai-writing` 扫描无阻断项,复核项
中的权限边界、真实字段及测试列表按事实保留。
### 复现命令
在 GoodBuddy 仓库执行:
```powershell
npx vitest run src/main/device-sharing-service.test.ts src/main/device-sharing-ipc.test.ts src/main/application-settings-store.test.ts src/renderer/src/DeviceSharingPage.test.tsx src/renderer/src/ApplicationCenter.test.tsx src/renderer/src/ApplicationMenu.test.tsx src/preload/preload-sandbox.test.ts
npx vitest run src/renderer/src/App.test.tsx
npx vitest run src/shared/goodbuddy-config-contracts.test.ts
$env:GOODBUDDY_SHARING_SERVER_ROOT = (Resolve-Path ../goodbuddy-shareserver).Path
npx vitest run tests/device-sharing.electron.test.ts
npx vitest run tests/inline-help.electron.test.ts
npm run typecheck
npm run lint
npm test
```
跨仓 Electron 测试需要后端已安装依赖;未设置 `GOODBUDDY_SHARING_SERVER_ROOT` 时明确跳过。
测试会关闭自己启动的服务并清理临时数据,不写后端源码。
### 手工体验
在 ShareServer 仓库运行 `npm run dev:api`,在 GoodBuddy 仓库运行 `npm run dev`。先从“应用 →
管理应用 → 设备共享 → 应用设置”启用,再从应用菜单打开“设备共享”,默认地址为
`http://127.0.0.1:8787`。先保存名称或地址修改,再注册本机、
发布条目、检查目录、撤销并刷新。知识类型提供三个独立权限框;重启后可比较同一设备 ID。
服务关闭时刷新应显示错误并保留旧列表,不应显示为空目录或推断设备离线。
@@ -4,13 +4,47 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 当前首期实现
本节实现 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据)。后文模块树、SQLite 表、共享协议包、
局域网与网关协议都是后续设计,当前不创建这些基础设施。
`src/shared/device-sharing-contracts.ts` 定义严格输入和返回类型;`DesktopApi.sharing` 暴露
`getSettings`、`saveSettings`、`registerDevice`、`getCatalog`、`publish`、`revoke` 六个方法。
生产 preload 对应六个固定 IPC 通道,`device-sharing-ipc.ts` 对每次调用检查可信主窗口主帧,
对变更输入进行 Zod 校验,不暴露通用网络入口。
`DeviceSharingService` 在 Main 内使用 `fetch`,每个请求含响应体读取采用 10 秒超时,非成功
响应保留 HTTP 状态;响应再经 Zod 校验。请求只在页面读取或用户动作时发出,不启动后台发现。
URL 接受 HTTP/HTTPS,禁止内嵌凭据、查询和片段。没有令牌或 Agent/Runtime 改动。
| 方法 | HTTP 请求 |
| --- | --- |
| 注册 | `POST /api/v1/sharing/devices/register`,字段为 `id,name,platform,appVersion` |
| 设备列表 | `GET /api/v1/sharing/devices`,读取 `{devices:[]}` |
| 发布列表 | `GET /api/v1/sharing/publications`,读取 `{publications:[]}` |
| 发布 | `POST /api/v1/sharing/publications`,Main 生成条目 ID 并填入本机设备 ID |
| 撤销 | 先按 `?deviceId=` 获取本机条目,再 `POST /api/v1/sharing/publications/:id/revoke` |
设备 ID、名称与服务 URL 存在用户数据目录的 `device-sharing-settings.json`,版本为 1;
复用 `writeJsonFileAtomically`,加载 Promise 去重、保存串行。损坏设置报错而不重置 ID。
此文件不保存目录副本或凭据。导航沿用已有存储归一化,把 `device-sharing` 追加到旧顺序,
保留既有应用相对顺序与设置;新入口可排序,不增加常驻字段。应用设置中的
`deviceSharingEnabled` 使用共享 schema 的 `false` 默认值补齐缺失字段,显式 `true` 保留;
沿用现有版本与原子持久化、变更事件。更新 schema 将该字段设为可选,避免无关更新重置选择。
菜单复用启用过滤,App 收到关闭设置时返回对话并卸载页面。共享服务和目录数据不受此开关修改。
服务测试使用真实 loopback HTTP,验证契约、独立权限、识别范围、持久化、错误和超时;
Electron 测试通过环境变量选择相邻真实 ShareServer 源码,使用临时服务数据与桌面设置。
具体命令和实测边界见[实施进度](./progress.md)。
## 1. 进程边界
```text
+21 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 状态 | 本地元数据首期已接入;完整共享网络为后续设计 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
@@ -14,6 +14,26 @@
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
全局设计系统为准。
## 当前首期页面
本节对应 [FR-M1 至 FR-M5](./prd.md#当前首期本地元数据),后文四页签、配对及调用界面为后续设计。
应用中心显示“可选”及启用状态,测试阶段默认关闭,此时“打开”禁用。应用设置复用共享
Switch,常显测试状态和关闭后果,不提供常驻开关。启用后应用菜单显示入口,关闭时退出
设备共享页面并返回对话。主内容区使用 `standard` PageShell、PageHeader 与全局范围徽标。
标题下常显未认证、ID 识别用途和无执行/内容访问的边界。内容依次为本机设置、已注册设备、
发布表单和发布目录。服务 URL 与设备名称可编辑,设备 ID 只读;注册、保存与刷新为次操作,
“发布元数据”为唯一主按钮。
类型与来源使用短标签原生选择框;知识的搜索、读取、下载使用三个独立 Checkbox。服务器来源
不上传或同步内容的说明常显。发布按钮附近显示已保存的目标服务及设备名称,尚未注册或
配置未保存时给出操作提示。列表展示服务返回的名称、标识、平台/类型、更新时间和发布状态,
不展示在线灯或执行按钮。
撤销使用共享危险入口和就地确认,说明保留记录且不删除本机内容。成功与操作失败通过应用
通知反馈;目录加载失败在页面显示并由刷新按钮恢复。宽度收窄时按钮换行,长 URL 和 ID 换行;
表单复用 `.field`,颜色与间距使用共享令牌。页面文案提供中英文。
## 1. 信息架构
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
@@ -33,7 +33,7 @@
| 用户任务 | 主入口与路由 | 范围及目标行为 |
| --- | --- | --- |
| 配置解析与 OCR | 侧栏底部“设置” → “文档解析”(`document-parsing`) | 设备级共享配置,分别保留聊天与知识库工作流;HTTP 地址、凭据和高级选项只在此维护,不随当前项目或会话切换 |
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 |
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;更换下载源入口直接打开“模型连接”页头的模型下载源弹窗,与 OCR 来源选择分开;弹窗交互见[模型下载源设计](../../architecture/model-download-source-design.md#7-模型下载源界面) |
| 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 |
| 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 |
| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 打开共享预览;来源消息与目标草稿分别显示,历史输入只读 |
@@ -51,6 +51,10 @@
菜单项包含附件名称以区分多个文件。普通图片的“提取图片文字”可作为就近次操作,不能和
菜单形成两套状态或重复执行。菜单复用 `AnchoredMenu` 的定位、键盘和焦点恢复。
草稿与队列中的图片缩略图可点击进入共享图片查看器,优先显示已保存的完整图片,只有
缩略图时使用缩略图预览;查看不触发 OCR。队列附件菜单只提供打开原文件,不读取 OCR
来源或显示解析设置;修改发送方式须先恢复到草稿。解析结果入口仅在已有结果时显示。
草稿、历史消息和队列预览中的“查看解析结果”“更多附件操作”使用共享 `icon-button`
视觉,图标分别为文档查阅和省略号,点击区域为 `32px × 32px`,在独立操作行内排列。
按钮提供含文件名的可访问名称、悬停提示及键盘焦点提示;草稿移除和导入取消使用同尺寸
@@ -129,7 +129,10 @@ Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平
> 以及你主动添加的截图。默认不上传桌面诊断;只有勾选后才会把有界诊断摘要追加到描述。
> 不会发送对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。
Checkbox 下方持续说明:诊断摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息。
Checkbox 下方持续说明:诊断摘要含时间、组件、阶段、稳定错误码、错误类型和固定短消息,
以及工具连接失败的类别、耗时、HTTP 状态、尝试次数、工具类型和匿名关联标识。
MCP 附加字段由 `normalizeDesktopDiagnosticRecord` 在写入、读取和反馈导出时按白名单
校验;关联标识为请求令牌的 SHA-256 摘要,不含原始令牌、地址或错误正文。
Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详细描述上限为 3,398 字符,为附加
摘要预留 1,600 字符以及分隔换行;超长草稿保留在表单中并显示字段错误,不静默截断。
@@ -911,8 +911,8 @@ Endpoint、编码配置或服务端稳定模型摘要变化。
- 目录只包含元数据,不包含模型权重。
- 每个来源的文件固定不可变 Revision、URL、字节数和 SHA-256。
- 实际下载使用“平台功能 → 通用设置”中显式选择的 ModelScope 或 Hugging Face,完整
来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
- 实际下载使用“模型连接”页头的模型下载源弹窗中确认保存的 ModelScope 或 Hugging Face;
向量模型的下载源入口直接打开该弹窗,完整来源契约以[平台功能页签与模型下载源设计](../../architecture/model-download-source-design.md)为准。
- 下载写入随机暂存目录和 `.partial` 文件。
- 禁止未声明重定向到其他主机。
- 每个文件边下载边计算摘要。
+3 -2
View File
@@ -22,8 +22,9 @@ comments. The divider can be dragged and remembers its width; each note group
can independently collapse its tasks while retaining its title and count.
Narrow containers use on-demand detail with an explicit return to the
mounted list. One content-sized button switches to the other view in the page
header when note detail is closed; New note lives in the notes list heading or the
To-dos page header. Status filters sit beside search and wrap
header when note detail is closed; New note is always the primary page-header
action to the right of the view switch or Back to overview. The notes list
heading keeps only Manage tags. Status filters sit beside search and wrap
when needed. Note detail removes the surrounding frame and stream padding while
keeping editor borders and the AI divider. Returning from a source note restores the task
context and protects unsaved editor changes. A successful save followed by no
+10
View File
@@ -1,5 +1,15 @@
# Magic Notes Progress
## 2026-10-01: Header New Note Action
New note is now one primary page-header button that always follows the view
switch (overview, to-dos) or Back to overview (note detail). The `+` icon in the
notes list heading was removed; the heading keeps only Manage tags. From note
detail the button opens the list's title form (desktop reopens a collapsed list;
narrow returns to the list first). Workspace tests (133) and the Electron layout
test pass, with overview and detail screenshots checked at 1280px; the App note
subset (11), typecheck, and lint on changed files pass.
## 2026-09-30: Navigate After Record Mount
Quick Notes navigation now scrolls after React commits the target record, rather
+10
View File
@@ -67,6 +67,8 @@ Runtime 状态尚未匹配当前选择时按钮仍禁用,点击不会发出请
同日 CI 再现快速笔记打开完整记录后未滚动:数据请求完成后的动画帧仍可能早于
React 提交目标 DOM。定位操作应在目标记录提交后的布局 Effect 中执行,
而不是在请求 Promise 完成时猜测渲染时机;立即执行动画帧的回归可稳定暴露旧问题。
2026-10-01 磨砂外观 CI 在主题切换后读到窗口按钮颜色过渡的中间值。比较稳定样式前,
等待当前 CSS Transition 的 `finished`,不要固定睡眠、删除颜色断言或等待无限循环动画。
## 整窗遮罩与原生拖动区域
@@ -293,6 +295,14 @@ Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;
**注意**:React 允许在渲染期用守卫调用 `setState` 来响应 props 变化,这是官方推荐做法,不要
把它改写成 `useEffect`。仓库 lint 规则 `react-hooks/set-state-in-effect` 会拒绝后者。
2026-09-30 的 App 回归还确认两类关联开销:内联 JSX、回调和空数组使 memo 失效;
`useRef(hasConversationMigrationStorage())` 在每次输入时执行存储读取。初始化读取应延迟到
首次初始化,缓存面板的 props 应保持稳定。即时用户动作可在调用时读取当前会话;异步
草稿恢复、语音和附件操作须保留发起会话,不能统一改成完成时读取活动会话。
App 集成回归应计数 memo 边界内的真实渲染,覆盖输入、热切换和后续流式更新;仅测独立
消息组件无法发现父组件传入的不稳定 props。测量结果见
[PERF-10](../roadmap/product-performance-experience-improvement-plan.md#perf-10-keep-alive-轻量优化)。
## 解析警告被当作提取正文
**症状**:OCR 没有识别文字,但缺图警告让图片附件切成文字发送,或让仅图片结果被判为
@@ -372,14 +372,24 @@ frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-10 Keep-alive 轻量优化
- **优先级 / 状态:** P2 / 待开始
- **实施条件:** `PERF-09` 确认当前缓存不达标。
- **优先级 / 状态:** P2 / 已完成输入与热切换渲染隔离,压力测试待完成
- **实施依据:** 2026-09-30 的真实 App 组件回归确认输入和热切换会重复渲染缓存内容;
`PERF-09` 的 Electron 内存与 GC 压力测试仍待执行。
- **范围:**
- 优先 memoize 隐藏 `ChatHistoryPane` 并稳定 props。
- `ChatHistoryPane`、任务条和 Runtime 清单使用 memo,稳定快捷输入、重试、图片恢复、
定时任务回调及任务条 JSX。异步草稿操作仍绑定发起时的会话。
- 仍不达标时才调整缓存数量或一小时有效期。
- 暂不引入完整虚拟化框架。
- **验收:** 内存和 commit 指标改善,同时保留会话切换位置和快速恢复体验。
`App.test.tsx` 修复前回归记录:运行请求期间连续输入三次,历史面板、任务条、清单各
渲染三次,会话迁移存储读取三次;两个已缓存会话往返一次,Markdown 渲染 324 次。
长会话已展开 160 条消息,另一会话含一条消息,计数包含展开的推理正文。修复后上述
计数均为零。最终用例还覆盖可见且展开的运行中任务条、实时文本与清单更新、停止、
会话草稿、图片素材选择期间切换会话,以及滚动位置和推理展开状态保留。
这些是 jsdom 中真实 App 的渲染次数,使用 mock preload;尚未测量 Electron 输入延迟、
绘制耗时或内存。流式事件未延后,缓存容量和有效期未改变。
## 8. 体验与可访问性
### UX-01 统一 Ask/Execute 文案
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.8",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.15.5",
"version": "0.15.8",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+25
View File
@@ -0,0 +1,25 @@
# GoodBuddy Agent 0.15.2 更新内容
## 功能更新
- **把会话图片保存到 Host**:配套 Desktop 0.15.8,在 Execute 模式中使用 `save_image`,可把当前会话中已上传或生成的图片保存到 Host 的指定绝对路径。Agent 分块接收 Desktop 中的图片并在 Host 写入,默认拒绝覆盖已有文件,只有明确指定覆盖时才替换;Ask 模式不能写入。
## 使用说明
- 先升级 Desktop **0.15.8**,再下载 Agent 并更新 Host;已有进程不热更新。
- 支持 PNG、JPEG 与原格式 WebP,不支持转换为 WebP;只接受当前会话可访问的图片,不下载服务商返回的图片 URL。
- 提供 Linux x64、Linux arm64 和 macOS arm64 包。Node **24.19.0**、OpenCode **1.18.29**、Continue **1.5.47** 不变。
- Agent 独立发布,不替代 Desktop Latest,保留签名与完整性验证。
# What's New in GoodBuddy Agent 0.15.2
## Features
- **Save conversation images on the Host**: With Desktop 0.15.8, use `save_image` in Execute mode to write an uploaded or generated image from the current conversation to an absolute path on the Host. Agent receives the image from Desktop in bounded chunks and writes it locally on the Host, refusing to replace an existing file unless overwrite is explicitly requested; Ask mode cannot write files.
## Usage Notes
- Upgrade to Desktop **0.15.8** before downloading Agent and updating the Host; existing processes are not hot-updated.
- PNG, JPEG, and unchanged WebP are supported; conversion to WebP is not. Only images accessible to the current conversation can be saved, and provider-returned image URLs are not downloaded.
- Packages cover Linux x64, Linux arm64, and macOS arm64. Node **24.19.0**, OpenCode **1.18.29**, and Continue **1.5.47** remain unchanged.
- Agent is published separately without replacing Desktop Latest, retaining signature and integrity verification.
+114
View File
@@ -1,6 +1,120 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.15.8",
"releasedAt": "2026-10-02",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**把会话图片保存为文件**:在 Execute 模式下,可让支持工具调用的 Runtime 使用 `save_image`,把当前会话已上传或生成的图片保存到指定绝对路径;远程项目保存到 Host,需配套 Agent 0.15.2。默认不覆盖已有文件,覆盖需明确指定;支持 PNG、JPEG 和原格式 WebP,不支持转换为 WebP,Ask 模式不提供写入能力。",
"**查看内置图像工具**:MCP 设置现在列出内置图像工具及说明,便于区分生成图片和保存已有图片。实际可用性仍由当前 Runtime、工作模式、模型配置及会话图片决定,列出工具不代表所有模式都能调用。"
],
"fixes": [
"**取消新笔记记录并恢复编辑焦点**:新建记录提供取消操作,未保存内容沿用草稿确认;退出已有记录编辑后,焦点回到对应编辑入口。笔记正文不再受 880px 最大宽度限制,使用可用内容区宽度。",
"**内置浏览器默认可用**:新配置和缺少内置浏览器设置的旧配置默认启用,并分配给直连模型、OpenCode 和 Continue;已明确保存的启停与分配选择保持不变。仍需 Execute 模式才能让 Agent 操作浏览器,不控制用户已安装的浏览器。",
"**透明磨砂默认开启**:新用户及缺少外观字段的旧配置默认开启透明磨砂,已保存的选择不被覆盖。可在外观设置关闭,效果仅在应用内部呈现,不透出系统桌面。"
],
"notices": [
"远程保存图片请先升级 Desktop 0.15.8,再下载 Agent 0.15.2 并更新 Host;已有 Agent 进程不会热更新。本轮数据库仍为 schema 52,升级前请关闭应用并备份完整数据库及相邻 `notes/` 目录。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Save conversation images as files**: In Execute mode, a tool-capable Runtime can use `save_image` to write an uploaded or generated image from the current conversation to an absolute path; remote projects write on the Host and require Agent 0.15.2. Existing files are protected unless overwrite is explicitly requested; PNG, JPEG, and unchanged WebP are supported, conversion to WebP is not, and Ask mode cannot write files.",
"**Inspect built-in image tools**: MCP settings now list built-in image tools and descriptions so you can distinguish image generation from saving an existing image. Availability still depends on the Runtime, work mode, model configuration, and conversation images; listing a tool does not make it callable in every mode."
],
"fixes": [
"**Cancel new note entries and restore editing focus**: New entries now have a cancel action with existing unsaved-draft confirmation, and leaving an existing entry's editor restores focus to its edit control. Note content uses the available pane width instead of an 880px maximum.",
"**Enable the built-in browser by default**: New configurations and older configurations without a browser preference enable it for the direct model, OpenCode, and Continue, while explicitly saved enablement and assignments remain unchanged. Agent browser operations still require Execute mode and do not control your separately installed browser.",
"**Enable frosted-glass appearance by default**: New users and older configurations without the appearance field start with the effect enabled, without overriding saved choices. You can disable it in Appearance; the effect stays inside the app and does not reveal the operating-system desktop."
],
"notices": [
"For remote image saving, upgrade to Desktop 0.15.8 before downloading Agent 0.15.2 and updating the Host; existing Agent processes are not hot-updated. The database remains at schema 52; close the app and back up the complete database and adjacent `notes/` directory before upgrading."
]
}
}
},
{
"version": "0.15.7",
"releasedAt": "2026-10-01",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**减少流式回复期间的重复渲染**:较长回答持续输出时,已完成的 Markdown 块可复用,仅重新解析增长中的尾部,并减少会话侧栏和活动摘要的重复计算。涉及全文语义的内容仍使用完整解析,默认流式刷新节奏保持不变;性能测量来自限定的组件场景,不代表所有设备或完整应用的提速比例。",
"**运行记录按需刷新**:进入运行记录页面时读取快照,页面可见期间每 15 秒刷新一次,也可使用页头统一刷新按钮更新记录与 Token 用量。页面不再随每个实时事件重新计算,显示可能存在短暂延迟。",
"**单个自定义 MCP 故障不再拖垮正常工具**:本地 Runtime 发现工具时,失败服务的工具被排除,正常服务仍可使用;动态目录刷新失败会清空该服务的旧工具,避免继续使用失效目录。首次连接失败的服务在下一请求重试,取消与授权撤销继续生效;这不是远程 Agent 的新恢复能力。",
"**在模型连接中统一选择下载源**:模型类型页签右侧显示当前受管模型下载源,可打开弹窗选择 ModelScope 或 Hugging Face,确认后才保存,取消不改变已保存选择。向量、语音和 OCR 的更换来源入口直接打开同一弹窗;设置只影响后续受管本地模型下载,不会静默换源或修改模型 API、应用更新和 Agent 包来源。",
"**设备共享测试功能改为显式启用**:先在应用中心启用设备共享,再从应用菜单或应用中心进入;缺失启用设置时默认关闭,已明确保存的选择保留。关闭会退出页面,但不清除目录或撤销已发布记录;仍需另行运行 ShareServer,仅管理元数据,不提供远程执行或知识正文访问。"
],
"notices": [
"本轮只更新 Desktop,Agent 保持 **0.15.1**,无需重新下载 Agent 包。数据库仍为 schema 52;从较旧版本升级前,请关闭应用并备份完整数据库及相邻 `notes/` 目录,降级需恢复升级前备份。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Reduce repeated rendering during streamed replies**: Completed Markdown blocks can be reused while only the growing tail is parsed, with less repeated work for conversation rows and activity summaries. Content with document-wide semantics still uses full parsing, and the default streaming cadence is unchanged; component benchmarks do not establish a speedup for every device or the complete application.",
"**Refresh activity records on demand**: Opening the activity page loads a snapshot, refreshes it every 15 seconds while visible, and offers one header button to refresh records and token usage. The page no longer recalculates for every live event, so displayed records may briefly lag behind.",
"**Keep healthy tools available when one custom MCP service fails**: Local Runtime tool discovery excludes failed services while retaining healthy ones, and a failed dynamic catalog refresh clears that service's stale tools. Initially failed connections are retried on the next request, while cancellation and authorization revocation remain effective; this is not a new remote Agent recovery capability.",
"**Choose managed model download sources in Model Connections**: The model-type tab row shows the current source and opens a dialog to select ModelScope or Hugging Face, saving only on confirmation and preserving the saved choice on cancel. Embedding, speech, and OCR source-change entries open the same dialog; this affects subsequent managed local model downloads only, without silent source fallback or changes to model APIs, application updates, or Agent package sources.",
"**Explicitly enable the device-sharing test feature**: Enable Device Sharing in Application Center before opening it from the application menu or manager; a missing preference defaults to off, while explicitly saved choices are preserved. Disabling it exits the page without clearing catalogs or revoking publications; a separately running ShareServer is still required, and this remains metadata management without remote execution or knowledge-content access."
],
"notices": [
"This is a Desktop-only update; Agent remains **0.15.1**, with no Agent package download required. The database remains at schema 52; before upgrading from older releases, close the app and back up the complete database and adjacent `notes/` directory, and restore a pre-upgrade backup to downgrade."
]
}
}
},
{
"version": "0.15.6",
"releasedAt": "2026-10-01",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**可选的透明磨砂外观**:在外观设置中开启“透明磨砂特效”,聊天内容可滚动经过顶栏背后,执行清单浮层也使用磨砂背景。默认关闭,选择会保存并在重启后恢复;效果只作用于应用内部,不透出操作系统桌面。",
"**设备共享元数据入口**:从应用菜单或应用管理打开“设备共享”,连接另行运行的 ShareServer 服务后,可注册本机、浏览设备和发布目录、发布能力或知识的元数据并撤销本机 ID 的条目。首期面向本地未认证服务,设备 ID 不是所有权认证;不执行远程能力,不搜索、读取或下载知识正文,权限选项仅为元数据声明。"
],
"fixes": [
"**新建笔记入口保持可见**:“新建笔记”统一放在魔法笔记页面标题栏,不再藏在列表工具栏中。打开详情或待办时仍可发起新建,并沿用未保存草稿保护。",
"**改善本地 OpenCode 工具连接恢复**:明确的暂时性 MCP 传输故障会在清理成功后最多重试一次,超时或取消后结果不确定的连接名称不再复用,避免晚到初始化覆盖新连接。鉴权、协议和未知错误不会自动重试,Prompt 与工具调用不会重放;该修正不代表远程 Agent 新增恢复能力。",
"**减少输入和切换会话时的重复处理**:输入时未变化的消息历史及任务条不再反复渲染,切换已缓存会话时复用稳定的消息回调。实时输出、滚动位置、草稿和异步附件归属保持不变,执行统计索引也减少了轮询时对事件记录的扫描。",
"**草稿和队列图片可直接放大预览**:点击图片缩略图即可打开共享查看器,无需先执行 OCR。队列附件保持只读,不再展示解析来源和设置入口;草稿仍可显式提取文字。",
"**整理设置导航与笔记间距**:设置按分组排列,持久开关统一右对齐,侧栏底部文字布局一并修正。笔记列表、详情和评论方向栏的标题对齐,文字编辑工具栏与输入区域减少多余留白,保留画布间距。",
"**补充本地 MCP 故障诊断**:反馈诊断可包含连接阶段、错误类别及关联摘要,帮助定位初始化和清理问题。诊断不记录令牌、服务地址、请求正文或原始错误内容。"
],
"notices": [
"首次启动升级到 schema 52,为任务事件增加执行统计索引;历史较多时建立索引可能需要更长时间。升级前请关闭应用并备份完整数据库及相邻 `notes/` 目录;旧客户端不能打开升级后的数据库,降级需恢复升级前备份。",
"本轮只更新 Desktop,Agent 保持 **0.15.1**,无需更新 Agent 包。仅提供标准六平台产物,不包含新的 LoongArch 预览。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Optional frosted-glass appearance**: Enable the effect in Appearance to let chat content scroll behind the header and give execution-checklist panels a frosted background. It is off by default and your choice persists across restarts; the effect is internal to the app and does not reveal the operating-system desktop.",
"**Device-sharing metadata entry**: Open Device Sharing from the application menu or manager and connect to a separately running ShareServer to register this device, browse device and publication catalogs, publish capability or knowledge metadata, and revoke entries associated with this device ID. This first stage targets a local unauthenticated service, and device IDs are not ownership authentication; it does not execute remote capabilities or search, read, or download knowledge content, and permission options are metadata declarations only."
],
"fixes": [
"**Keep New note visible**: New note now stays in the Magic Notes page header rather than the list toolbar. You can start a note while viewing details or to-dos, with existing unsaved-draft protection retained.",
"**Improve local OpenCode tool connection recovery**: Explicit transient MCP transport failures receive at most one retry after successful cleanup, and names with uncertain timeout or cancellation outcomes are retired so late initialization cannot replace a live connection. Authentication, protocol, and unknown errors are not retried, and prompts or tool calls are not replayed; this is not a new remote Agent recovery capability.",
"**Reduce repeated work while typing and switching conversations**: Unchanged message history and task strips avoid repeated renders during typing, with stable callbacks reused across cached conversation switches. Streaming, scroll positions, drafts, and asynchronous attachment ownership remain intact, while execution-statistics indexes reduce event scans during polling.",
"**Preview draft and queued images directly**: Click an image thumbnail to open the shared viewer without running OCR first. Queued attachments remain read-only and no longer show parsing-source or settings entries; drafts retain explicit text extraction.",
"**Organize settings and tighten note spacing**: Settings navigation is grouped, persistent switches align to the right, and sidebar footer text layout is corrected. Note-list, detail, and comment-direction headers align, with excess gaps removed around text editing controls while preserving canvas spacing.",
"**Add local MCP failure diagnostics**: Feedback diagnostics can include connection phases, error categories, and correlation digests to help identify initialization and cleanup failures. They do not record tokens, service addresses, request bodies, or raw error content."
],
"notices": [
"First launch upgrades to schema 52 and adds task-event indexes for execution statistics; building indexes may take longer with large histories. Close the app and back up the complete database and adjacent `notes/` directory first; older clients cannot open the upgraded database, so downgrading requires a pre-upgrade backup.",
"This is a Desktop-only update; Agent remains **0.15.1**, with no Agent package update required. Only the six standard targets are released, with no new LoongArch preview."
]
}
}
},
{
"version": "0.15.5",
"releasedAt": "2026-09-30",
+43 -3
View File
@@ -1,6 +1,9 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { randomBytes, randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -46,7 +49,7 @@ async function setup() {
service.initialize()
cleanup.push(() => service.dispose())
const frames: Uint8Array[] = []
const connect = async (mode: 'ask' | 'execute' = 'execute') => {
const connect = async (mode: 'ask' | 'execute' = 'execute', options: { save?: boolean } = {}) => {
const binding = service.bind({ ...context, requestId: randomUUID(), workMode: mode })
const queue: RuntimeProtocolBinaryFrame[] = []
let receiver: ((frame: RuntimeProtocolBinaryFrame) => void) | undefined
@@ -62,7 +65,7 @@ async function setup() {
}
const wait = new AbortController()
const main = new MainImageToolSession(channel, binding, wait.signal)
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', description: await binding.describe() ?? 'Not available' }, async payload => {
const adapter = new AgentImageToolMcp({ channelId: channel.channelId, channelEpoch: '1', description: await binding.describe() ?? 'Not available', ...(options.save ? { saveDescription: 'Save image' } : {}) }, async payload => {
if (closed) throw new Error('disconnected')
frames.push(payload)
const frame = { payload, sequence: '1', consume: async () => {} }
@@ -130,6 +133,43 @@ describe('remote image MCP to Main service', () => {
expect((await execute.client.listTools()).tools[0]!.description).toContain('Changed next prompt')
})
it('saves a conversation image on the Agent host by pulling bounded chunks from Desktop', async () => {
const h = await setup()
const plain = await h.connect()
expect((await plain.client.listTools()).tools.map(tool => tool.name)).not.toContain('save_image')
const remote = await h.connect('execute', { save: true })
expect((await remote.client.listTools()).tools.map(tool => tool.name)).toContain('save_image')
// Larger than one 128 KiB chunk so the Agent must reassemble several replies.
const large = Buffer.concat([Buffer.from(png, 'base64'), randomBytes(300 * 1024)])
const [artifactId] = h.service.persistUploads(h.context, [{ name: 'large.png', mediaType: 'image/png', data: large.toString('base64') }])
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
const target = join(directory, 'nested', 'out.png')
const saved = await remote.client.callTool({ name: 'save_image', arguments: { artifactId, path: target } })
expect(saved.isError).not.toBe(true)
expect(JSON.parse((saved.content as { text: string }[])[0]!.text)).toMatchObject({ artifactId, path: target, mimeType: 'image/png', byteSize: large.byteLength })
expect((await readFile(target)).equals(large)).toBe(true)
expect(h.frames.length).toBeGreaterThan(4)
expect(h.frames.every(frame => frame.byteLength <= 256 * 1024)).toBe(true)
const again = await remote.client.callTool({ name: 'save_image', arguments: { artifactId, path: target } })
expect(again.isError).toBe(true)
expect(JSON.stringify(again.content)).toContain('already exists')
const foreign = await remote.client.callTool({ name: 'save_image', arguments: { artifactId: randomUUID(), path: join(directory, 'x.png') } })
expect(foreign.isError).toBe(true)
expect(JSON.stringify(foreign.content)).toContain('not available in this conversation')
})
it('rejects remote saves in Ask mode at the desktop binding', async () => {
const h = await setup()
const [artifactId] = h.service.persistUploads(h.context, [{ name: 'source.png', mediaType: 'image/png', data: png }])
const ask = await h.connect('ask', { save: true })
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-save-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
const denied = await ask.client.callTool({ name: 'save_image', arguments: { artifactId, path: join(directory, 'out.png') } })
expect(denied.isError).toBe(true)
await expect(readFile(join(directory, 'out.png'))).rejects.toThrow()
})
it('returns unknown delivery without sending a second request for the same MCP call', async () => {
const send = vi.fn(async () => { throw new Error('connection lost after send') })
const adapter = new AgentImageToolMcp({ channelId: randomUUID(), channelEpoch: '1', description: 'Images' }, send)
+56 -5
View File
@@ -1,14 +1,19 @@
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { createServer, type Server } from 'node:http'
import { dirname, isAbsolute } from 'node:path'
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { CallToolRequestSchema, ListToolsRequestSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { imageToolInputSchema, imageToolName } from '../shared/image-generation-contracts'
import {
imageSaveMimeTypeForPath, imageSaveToolInputSchema, imageSaveToolName,
imageToolInputSchema, imageToolName, type ImageSaveResult, type ImageSaveToolInput
} from '../shared/image-generation-contracts'
import { storyGraphTools, type StoryGraphToolName } from '../shared/story-graph-tools'
import {
decodeRemoteImageToolMessage, encodeRemoteImageToolMessage,
remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema
remoteImageSaveChunkSchema, remoteImageToolMaximumBytes, remoteImageToolReplySchema, type remoteImageToolSchema
} from '../shared/remote-image-tool-contracts'
/** One prompt's local MCP adapter. It never holds provider credentials or image bytes. */
@@ -31,6 +36,8 @@ export class AgentImageToolMcp {
const path = `/${randomUUID()}/mcp`
const schema = z.toJSONSchema(imageToolInputSchema, { target: 'draft-7', io: 'input' })
Reflect.deleteProperty(schema, '$schema')
const saveSchema = z.toJSONSchema(imageSaveToolInputSchema, { target: 'draft-7', io: 'input' })
Reflect.deleteProperty(saveSchema, '$schema')
this.server = createServer((request, response) => {
void (async () => {
if (this.closed || request.url !== path) { response.writeHead(404).end(); return }
@@ -50,6 +57,8 @@ export class AgentImageToolMcp {
const graphEnabled = available?.content.some(part => part.type === 'text' && JSON.parse(part.text).available === true)
return { tools: [
...(this.descriptor.description ? [{ name: imageToolName, description: this.descriptor.description, inputSchema: schema as { type: 'object' } }] : []),
...(this.descriptor.saveDescription ? [{ name: imageSaveToolName, description: this.descriptor.saveDescription, inputSchema: saveSchema as { type: 'object' },
annotations: { readOnlyHint: false, destructiveHint: true } }] : []),
...(graphEnabled ? storyGraphTools.map(tool => ({ name: tool.name, description: tool.description,
inputSchema: z.toJSONSchema(tool.inputSchema, { target: 'draft-7', io: 'input' }) as { type: 'object' },
annotations: { readOnlyHint: true, destructiveHint: false } })) : [])
@@ -61,6 +70,14 @@ export class AgentImageToolMcp {
try { return await this.call(call.params.arguments ?? {}, graphTool.name) }
catch (error) { return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Story Graph read failed' }] } }
}
if (call.params.name === imageSaveToolName && this.descriptor.saveDescription) {
try {
const saved = await this.saveImage(imageSaveToolInputSchema.parse(call.params.arguments ?? {}))
return { content: [{ type: 'text', text: JSON.stringify(saved) }] }
} catch (error) {
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : 'Saving image failed' }] }
}
}
if (call.params.name !== imageToolName || !this.descriptor.description) throw new Error('Unknown image tool')
const input = imageToolInputSchema.parse(call.params.arguments)
// Repeated delivery of an MCP request returns the same outcome, including unknown delivery.
@@ -99,8 +116,42 @@ export class AgentImageToolMcp {
const reply = remoteImageToolReplySchema.parse(decodeRemoteImageToolMessage(payload))
const pending = this.pending.get(reply.callId)
if (!pending) return
if (reply.error || (!reply.result && !reply.storyGraphResult)) pending.reject(new Error(reply.error ?? 'Tool response has no result'))
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.storyGraphResult ?? reply.result) }] })
if (reply.error || (!reply.result && !reply.storyGraphResult && !reply.imageChunk)) pending.reject(new Error(reply.error ?? 'Tool response has no result'))
else pending.resolve({ content: [{ type: 'text', text: JSON.stringify(reply.imageChunk ?? reply.storyGraphResult ?? reply.result) }] })
}
/** Pulls the encoded image from Desktop in bounded chunks and writes it on this host. */
private async saveImage(input: ImageSaveToolInput): Promise<ImageSaveResult> {
const target = input.path
if (!isAbsolute(target)) throw new Error('path must be an absolute file path')
const mimeType = imageSaveMimeTypeForPath(target)
if (!mimeType) throw new Error('path must end with .png, .jpg, .jpeg or .webp')
const chunks: Buffer[] = []
let offset = 0
let totalBytes: number | undefined
do {
const result = await this.call({ artifactId: input.artifactId, mimeType, offset }, 'save_image_read')
const part = result.content[0]
if (part?.type !== 'text') throw new Error('Image chunk response is invalid')
const chunk = remoteImageSaveChunkSchema.parse(JSON.parse(part.text))
if (chunk.offset !== offset || chunk.mimeType !== mimeType || (totalBytes !== undefined && chunk.totalBytes !== totalBytes)) {
throw new Error('Image chunk response is inconsistent')
}
totalBytes = chunk.totalBytes
const bytes = Buffer.from(chunk.data, 'base64')
if (bytes.byteLength === 0 || offset + bytes.byteLength > totalBytes) throw new Error('Image chunk response is inconsistent')
chunks.push(bytes)
offset += bytes.byteLength
} while (offset < totalBytes)
const data = Buffer.concat(chunks)
await mkdir(dirname(target), { recursive: true })
try {
await writeFile(target, data, { flag: input.overwrite ? 'w' : 'wx' })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('File already exists; choose another path or set overwrite=true', { cause: error })
throw error
}
return { artifactId: input.artifactId, path: target, mimeType, byteSize: data.byteLength }
}
close(): void {
@@ -112,7 +163,7 @@ export class AgentImageToolMcp {
this.server?.close()
}
private async call(input: Record<string, unknown>, name?: StoryGraphToolName | 'story_graph_list'): Promise<CallToolResult> {
private async call(input: Record<string, unknown>, name?: StoryGraphToolName | 'story_graph_list' | 'save_image_read'): Promise<CallToolResult> {
if (this.closed) throw new Error('Image tool prompt has ended')
const callId = randomUUID()
let timer: NodeJS.Timeout | undefined
+1 -1
View File
@@ -1173,7 +1173,7 @@ export class RuntimeAcpBackend {
}
}
}
if (preparation.imageTool && ((preparation.imageTool.description && preparation.workMode !== 'execute') || !this.#options.blobSink)) {
if (preparation.imageTool && (((preparation.imageTool.description || preparation.imageTool.saveDescription) && preparation.workMode !== 'execute') || !this.#options.blobSink)) {
throw new RuntimeAcpBackendError('Image tools require Execute and a desktop transport', 'identity')
}
if (requestedModelBridgePolicy !== undefined) {
+67 -1
View File
@@ -9,11 +9,13 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { KnowledgeService } from '../knowledge/knowledge-service'
import {
ContinueHostRunError,
type ContinueHostAdapterOptions
} from './continue-host-adapter'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
const mocks = vi.hoisted(() => ({
detectRuntimeBinary: vi.fn(),
@@ -464,6 +466,70 @@ describe('ContinueAgentRuntime', () => {
expect(gateway.grantCustomMcp).not.toHaveBeenCalled()
})
it('completes the Continue run when an assigned custom MCP server returns HTTP 503', async () => {
const requests = vi.fn((_request: IncomingMessage, response: ServerResponse) => {
response.writeHead(503, { Connection: 'close' })
response.end('Service unavailable')
})
const upstream = createServer(requests)
const gateway = new KnowledgeMcpGateway({} as KnowledgeService)
let runtime: ContinueAgentRuntime | undefined
try {
await new Promise<void>((resolve, reject) => {
upstream.once('error', reject)
upstream.listen(0, '127.0.0.1', resolve)
})
const address = upstream.address()
if (!address || typeof address === 'string') {
throw new Error('Custom MCP test server did not bind a TCP port')
}
await gateway.start()
runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: 'C:\\safe config\\continue.yaml',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
knowledgeGateway: gateway,
mcpServers: [{
id: randomUUID(),
name: 'Unavailable MCP',
description: '',
enabled: true,
allowDynamicTools: false,
assignments: ['continue'],
secretConfigured: false,
transport: 'http',
url: `http://127.0.0.1:${address.port}/mcp`
}],
createHostAdapter: () => ({
getPreparedHost: mocks.prepareHost,
run: mocks.runHost,
dispose: mocks.disposeHost
})
})
const events = await collectEvents(runtime, 'execute')
expect(requests).toHaveBeenCalled()
expect(mocks.runHost).toHaveBeenCalledOnce()
expect(events).toContainEqual({
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'text',
delta: 'Continue response'
})
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
await Promise.all([
runtime?.dispose(),
gateway.dispose(),
new Promise<void>((resolve) => {
upstream.close(() => resolve())
upstream.closeAllConnections()
})
])
}
})
it.each([undefined, 'existing-capability'])('cleans up image capabilities after custom MCP preparation fails (existing=%s)', async existingToken => {
const gateway = {
getEndpoint: () => 'http://127.0.0.1:4567/mcp',
+10
View File
@@ -73,6 +73,16 @@ function settings(
}
describe('createAgentRuntime model compatibility', () => {
it('passes the desktop failure observer to local OpenCode', async () => {
const observeFailure = vi.fn()
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
try {
expect(runtime).toMatchObject({ options: { observeFailure } })
} finally {
await runtime.dispose()
}
})
it('does not treat the default model profile as the platform DeepSeek source', () => {
const defaultProfile = {
id: '00000000-0000-4000-8000-000000000001',
+3
View File
@@ -44,6 +44,7 @@ import {
type ExecutionSpaceDescriptor
} from '../execution-space'
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const noModelTaskTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -58,6 +59,7 @@ const noModelTaskTools: ModelToolProviderLike = {
}
export type AgentCapabilityContext = {
observeFailure?: DesktopDiagnosticFailureObserver
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
skillPackages?: RuntimeSkillPackage[]
@@ -306,6 +308,7 @@ export function createAgentRuntime(
)
}
const options: OpenCodeRuntimeOptions = {
observeFailure: capabilities.observeFailure,
baseUrl,
embedded,
binaryPath:
+8 -4
View File
@@ -358,11 +358,15 @@ function proxyToolInputSchema(
delete scope.anyOf
return dshCompatibleMainInputSchema(schema)
}
return isMainWebTool(tool) || tool.name === 'generate_image'
return isMainWebTool(tool) || isMainImageTool(tool.name)
? dshCompatibleMainInputSchema(tool.inputSchema)
: tool.inputSchema
}
function isMainImageTool(name: string): boolean {
return name === 'generate_image' || name === 'save_image'
}
function boundedProxyToolCatalog(
tools: Awaited<
ReturnType<ModelToolProviderLike['listTools']>
@@ -376,7 +380,7 @@ function boundedProxyToolCatalog(
const catalog = tools.filter(
(tool) =>
isMainWebTool(tool) || isStoryGraphTool(tool.name) ||
(workMode === 'execute' && (tool.source === 'mcp' || tool.name === 'generate_image'))
(workMode === 'execute' && (tool.source === 'mcp' || isMainImageTool(tool.name)))
)
if (catalog.length > MAX_MCP_PROXY_TOOLS) {
throw new Error(
@@ -864,7 +868,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
)
const catalog = boundedProxyToolCatalog(tools, context.workMode)
this.proxyToolCatalogs.set(params.sessionId, tools.filter(
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || tool.name === 'generate_image'
(tool) => tool.source === 'mcp' || isMainWebTool(tool) || isStoryGraphTool(tool.name) || isMainImageTool(tool.name)
))
return { tools: catalog }
}
@@ -905,7 +909,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
const tool = tools.find(
(candidate) =>
candidate.name === name &&
(candidate.source === 'mcp' || candidate.name === 'generate_image' || isStoryGraphTool(candidate.name) ||
(candidate.source === 'mcp' || isMainImageTool(candidate.name) || isStoryGraphTool(candidate.name) ||
isMainWebTool(candidate))
)
if (!tool) {
@@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
@@ -8,9 +11,14 @@ import { imageOperationSchema, type ImageOperation } from '../../shared/image-ge
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { ModelToolProvider } from './model-tool-provider'
vi.mock('electron', () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, toPNG: () => Buffer.from(png, 'base64') }) } }))
vi.mock('electron', () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, toPNG: () => Buffer.from(png, 'base64'), toJPEG: () => Buffer.from('jpeg-bytes') }) } }))
const png = 'iVBORw0KGgo='
const cleanups: (() => void | Promise<void>)[] = []
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'goodbuddy-save-image-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
function setup(fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }] })), observers: Pick<ImageGenerationServiceOptions, 'onOperation' | 'onUsage' | 'onError'> = {}) {
@@ -222,10 +230,55 @@ describe('Main conversation image service', () => {
const client = new Client({ name: 'image-test', version: '1' })
cleanups.push(() => client.close())
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['generate_image'])
expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['generate_image', 'save_image'])
const result = await client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
expect(JSON.stringify(result)).toContain('completed')
expect(h.fetcher).toHaveBeenCalledOnce()
const operation = JSON.parse((result.content as { text: string }[])[0]!.text) as ImageOperation
const target = join(await tempDir(), 'mcp.png')
const saved = await client.callTool({ name: 'save_image', arguments: { artifactId: operation.artifactIds[0], path: target } })
expect(saved.isError).toBeFalsy()
expect((await readFile(target)).toString('base64')).toBe(png)
gateway.revoke(token)
})
it('saves conversation images to local files with conversion, overwrite protection and scoping', async () => {
const h = setup()
const created = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'save')
const artifactId = created.artifactIds[0]!
const binding = h.service.bind(h.context)
const dir = await tempDir()
const pngPath = join(dir, 'nested', 'out.png')
expect(await binding.save!({ artifactId, path: pngPath })).toMatchObject({ path: pngPath, mimeType: 'image/png' })
expect((await readFile(pngPath)).toString('base64')).toBe(png)
await expect(binding.save!({ artifactId, path: pngPath })).rejects.toThrow('already exists')
await expect(binding.save!({ artifactId, path: pngPath, overwrite: true })).resolves.toMatchObject({ path: pngPath })
const jpgPath = join(dir, 'out.jpg')
expect(await binding.save!({ artifactId, path: jpgPath })).toMatchObject({ mimeType: 'image/jpeg' })
expect((await readFile(jpgPath)).toString()).toBe('jpeg-bytes')
await expect(binding.save!({ artifactId, path: join(dir, 'out.webp') })).rejects.toThrow('WebP')
await expect(binding.save!({ artifactId, path: join(dir, 'out.gif') })).rejects.toThrow('.png')
await expect(binding.save!({ artifactId, path: 'relative.png' })).rejects.toThrow('absolute')
await expect(binding.save!({ artifactId: randomUUID(), path: join(dir, 'x.png') })).rejects.toThrow('not available')
await expect(h.service.bind(h.context, () => 'ask').save!({ artifactId, path: join(dir, 'ask.png') })).rejects.toThrow('Execute')
const provider = new ModelToolProvider(process.cwd())
cleanups.push(() => provider.dispose())
const context = { conversationId: h.context.conversationId, workMode: 'execute' as const, imageToolBinding: binding }
const native = await provider.callTool('save_image', { artifactId, path: join(dir, 'native.png') }, new AbortController().signal, context)
expect(JSON.stringify(native)).toContain('native.png')
await expect(provider.callTool('save_image', { artifactId, path: join(dir, 'ask2.png') }, new AbortController().signal, { ...context, workMode: 'ask' })).rejects.toThrow()
expect(h.fetcher).toHaveBeenCalledOnce()
})
it('offers save_image only when the conversation has images or can generate them', async () => {
const h = setup()
const binding = h.service.bind(h.context)
expect(await binding.describeSave!()).toMatch(/none yet/)
h.profile.allowConversationInvocation = false
expect(await binding.describeSave!()).toBeUndefined()
const [uploadId] = h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
// IDs are listed so the model can save images even when it cannot generate.
expect(await binding.describeSave!()).toContain(uploadId)
expect(await h.service.bind(h.context, () => 'ask').describeSave!()).toBeUndefined()
})
})
+75 -2
View File
@@ -1,7 +1,9 @@
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute } from 'node:path'
import {
imageToolDescription, imageToolInputSchema,
type ImageOperation, type ImageRequestContext, type ImageToolInput
imageSaveMaximumBytes, imageSaveMimeTypeForPath, imageSaveToolDescription, imageSaveToolInputSchema, imageToolDescription, imageToolInputSchema,
type ImageOperation, type ImageRequestContext, type ImageSaveResult, type ImageSaveToolInput, type ImageToolInput
} from '../../shared/image-generation-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import { ModelAgentRuntime, type ModelRuntimeOptions } from './model-runtime'
@@ -89,10 +91,81 @@ export class ImageGenerationService {
return active.operation
}
return this.getOperation(bound.conversationId, operation.id)
},
describeSave: async () => {
if (bound.workMode !== 'execute' || currentWorkMode() !== 'execute') return undefined
const conversation = this.options.database.getConversation(bound.conversationId)
// The model can only save IDs it has seen, so list them even when generate_image is unavailable.
const images = conversation.messages.flatMap(message => [
...(message.imageSourceArtifactIds ?? []).map(id => ({ artifactId: id, kind: 'upload' })),
...(message.imageOperations ?? []).flatMap(operation => operation.artifactIds.map(id => ({ artifactId: id, kind: 'generated' })))
]).slice(-32)
if (!images.length) {
const settings = await this.options.getSettings()
if (!settings.modelProfiles.some(profile => profile.protocol === 'openai-images-generations' && profile.allowConversationInvocation === true)) return undefined
}
return `${imageSaveToolDescription}\nConversation images (oldest first): ${images.length ? JSON.stringify(images) : 'none yet; images returned by generate_image can be saved'}`
},
save: async (input, signal) => {
if (bound.workMode !== 'execute' || currentWorkMode() !== 'execute') throw new Error('Saving images requires Execute mode')
return this.saveImage(bound.conversationId, imageSaveToolInputSchema.parse(input), signal)
},
readForSave: async (artifactId, mimeType) => {
if (bound.workMode !== 'execute' || currentWorkMode() !== 'execute') throw new Error('Saving images requires Execute mode')
return this.readImageForSave(bound.conversationId, artifactId, mimeType)
}
}
}
/** Writes a conversation-visible image artifact to an absolute local path. */
async saveImage(conversationId: string, input: ImageSaveToolInput, signal?: AbortSignal): Promise<ImageSaveResult> {
signal?.throwIfAborted()
const target = input.path
if (!isAbsolute(target)) throw new Error('path must be an absolute file path')
const targetMimeType = imageSaveMimeTypeForPath(target)
if (!targetMimeType) throw new Error('path must end with .png, .jpg, .jpeg or .webp')
const bytes = await this.readImageForSave(conversationId, input.artifactId, targetMimeType)
signal?.throwIfAborted()
await mkdir(dirname(target), { recursive: true })
try {
await writeFile(target, bytes, { flag: input.overwrite ? 'w' : 'wx' })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('File already exists; choose another path or set overwrite=true', { cause: error })
throw error
}
return { artifactId: input.artifactId, path: target, mimeType: targetMimeType, byteSize: bytes.byteLength }
}
/** Returns a conversation-visible image encoded as the requested format; used for local and remote saves. */
async readImageForSave(conversationId: string, artifactId: string, targetMimeType: ImageSaveResult['mimeType']): Promise<Buffer> {
const conversation = this.options.database.getConversation(conversationId)
const allowed = new Set(conversation.messages.flatMap(message => [
...(message.artifactIds ?? []), ...(message.imageSourceArtifactIds ?? []),
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
]))
if (!allowed.has(artifactId)) throw new Error('Image is not available in this conversation')
let artifact: ReturnType<AssistantDatabase['getArtifact']>
try {
artifact = this.options.database.getArtifact(artifactId)
} catch {
throw new Error('Image is not available in this conversation')
}
const match = artifact.kind === 'image'
? artifact.content?.match(/^data:(image\/(?:png|jpeg|webp));base64,([\s\S]+)$/u)
: undefined
if (!match) throw new Error('Artifact is not a saved image')
let bytes: Buffer = Buffer.from(match[2]!, 'base64')
if (targetMimeType !== match[1]) {
if (targetMimeType === 'image/webp') throw new Error('Converting to WebP is not supported; use .png or .jpg')
const { nativeImage } = await import('electron')
const image = nativeImage.createFromBuffer(bytes)
if (image.isEmpty()) throw new Error('Image could not be decoded for conversion')
bytes = targetMimeType === 'image/png' ? image.toPNG() : image.toJPEG(92)
}
if (bytes.byteLength > imageSaveMaximumBytes) throw new Error('Image is too large to save')
return bytes
}
persistUploads(context: Pick<ImageRequestContext, 'conversationId' | 'messageId'>, images: readonly AgentImage[]): string[] {
return this.options.database.saveConversationImageSources({ ...context, images })
}
+20 -1
View File
@@ -1,10 +1,20 @@
import { z } from 'zod'
import { imageToolInputSchema, imageToolName, type ImageOperation, type ImageRequestContext } from '../../shared/image-generation-contracts'
import {
imageSaveToolDescription, imageSaveToolInputSchema, imageSaveToolName,
imageToolInputSchema, imageToolName,
type ImageOperation, type ImageRequestContext, type ImageSaveResult
} from '../../shared/image-generation-contracts'
export type ImageToolBinding = {
context: Readonly<ImageRequestContext>
describe(): Promise<string | undefined>
call(input: unknown, callId: string, waitSignal?: AbortSignal): Promise<ImageOperation>
/** Writes an existing conversation image to a local file. Absent for bindings without local file access. */
save?(input: unknown, signal?: AbortSignal): Promise<ImageSaveResult>
/** Returns image bytes in the requested format so a remote Agent can write them on its own host. */
readForSave?(artifactId: string, mimeType: ImageSaveResult['mimeType']): Promise<Buffer>
/** save_image description with this conversation's image IDs; undefined when saving is not meaningful. */
describeSave?(): Promise<string | undefined>
}
export async function imageToolDefinition(binding: ImageToolBinding | undefined) {
@@ -14,3 +24,12 @@ export async function imageToolDefinition(binding: ImageToolBinding | undefined)
Reflect.deleteProperty(inputSchema, '$schema')
return { name: imageToolName, displayName: 'Generate or edit image', description, inputSchema, source: 'builtin' as const }
}
export async function imageSaveToolDefinition(binding: ImageToolBinding | undefined) {
if (!binding?.save || binding.context.workMode !== 'execute') return undefined
const description = binding.describeSave ? await binding.describeSave() : imageSaveToolDescription
if (!description) return undefined
const inputSchema = z.toJSONSchema(imageSaveToolInputSchema, { target: 'draft-7', io: 'input' })
Reflect.deleteProperty(inputSchema, '$schema')
return { name: imageSaveToolName, displayName: 'Save image to file', description, inputSchema, source: 'builtin' as const }
}
+147 -10
View File
@@ -24,6 +24,8 @@ import {
} from './knowledge-mcp-gateway'
import { browserTabIdSchema } from '../../shared/contracts'
import { BrowserService } from '../browser/browser-service'
import { createHash } from 'node:crypto'
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
@@ -178,6 +180,90 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = []
it('records safe HTTP rejection phases and isolates throwing observers', async () => {
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error('observer failed') })
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal)!
const correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
const cases = [
{ headers: {}, body: '{}', status: 401, phase: 'authentication' },
{ headers: { authorization: `Bearer ${token}` }, body: 'private-invalid-json', status: 400, phase: 'request' },
{ headers: { authorization: `Bearer ${token}`, 'mcp-session-id': 'private-session' }, body: '{}', status: 404, phase: 'session' },
{ headers: { authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } }), status: 406, phase: 'initialize' }
]
for (const entry of cases) {
const response = await fetch(gateway.getEndpoint()!, { method: 'POST', headers: { 'content-type': 'application/json', ...entry.headers }, body: entry.body })
await response.text()
expect(response.status).toBe(entry.status)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({
code: 'runtime.mcp.failed', mcp: expect.objectContaining({ phase: entry.phase, category: 'http-rejection', status: entry.status, elapsedMs: expect.any(Number) })
}))
}
expect(observeFailure.mock.calls.at(-1)?.[0]).toMatchObject({ mcp: { correlationId } })
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain(token)
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-')
})
it('cleans up failed HTTP initialization and allows a subsequent connection', async () => {
const observeFailure = vi.fn()
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('request', [firstLibraryId], new AbortController().signal)!
const connect = vi.spyOn(McpProtocolServer.prototype, 'connect').mockRejectedValue(new Error('private-connect-error'))
const close = vi.spyOn(McpProtocolServer.prototype, 'close')
for (let index = 0; index < 9; index += 1) {
const response = await fetch(gateway.getEndpoint()!, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: index, method: 'initialize', params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'test', version: '1' } } })
})
await response.text()
expect(response.status).toBe(500)
}
expect(close).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenCalledTimes(9)
expect(observeFailure).toHaveBeenLastCalledWith(expect.objectContaining({ mcp: expect.objectContaining({ phase: 'initialize', category: 'handler-failure', status: 500 }) }))
connect.mockRestore()
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await client.listTools()
await client.callTool({ name: 'knowledge_list', arguments: {} })
expect(observeFailure).toHaveBeenCalledTimes(9)
} finally { await client.close() }
})
it('persists asynchronous HTTP tool discovery failures without private error details', async () => {
const directory = await mkdtemp(join(tmpdir(), 'gateway-diagnostics-'))
temporaryDirectories.push(directory)
const diagnostics = new DesktopDiagnostics(directory)
const pending: Promise<void>[] = []
let clock = 100
vi.spyOn(performance, 'now').mockImplementation(() => clock)
const gateway = new KnowledgeMcpGateway(createService().service, {
observeFailure: failure => { pending.push(diagnostics.recordFailure(failure)) },
storyGraphService: { available: async () => { clock = 145; throw new Error('private-url-token-body') }, read: () => ({}) }
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant('private-request', [firstLibraryId], new AbortController().signal, 'none', undefined, undefined, undefined, undefined, undefined, { runtimeTarget: 'opencode' })!
const client = new Client({ name: 'test', version: '1' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { authorization: `Bearer ${token}` } } }))
await expect(client.listTools()).rejects.toThrow()
await Promise.all(pending)
const exported = (await diagnostics.exportRecent()).toString('utf8')
expect(JSON.parse(exported)).toMatchObject({ code: 'runtime.mcp.failed', mcp: { phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 45 } })
expect(exported).not.toContain('private-')
expect(exported).not.toContain(token)
} finally {
await client.close()
await diagnostics.dispose()
}
})
it('honors a requested knowledge result count above eight', async () => {
const { service, retrieveMany } = createService()
const [sample] = await retrieveMany([firstLibraryId])
@@ -1247,7 +1333,7 @@ describe('KnowledgeMcpGateway', () => {
expect(outputValidator).toHaveBeenCalledWith({ value: 'done' })
})
it('rejects cyclic cursors and custom MCP tool counts over 100', async () => {
it('omits servers with cyclic cursors or custom MCP tool counts over 100', async () => {
const cyclicUpstream = await startToolUpstream(
(cursor: string | undefined) => ({
tools: [testTool(cursor ? 'second' : 'first')],
@@ -1280,18 +1366,63 @@ describe('KnowledgeMcpGateway', () => {
await expect(
gateway.prepareCustomMcpTools(cycleToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('分页游标发生循环')
})
})
).resolves.toEqual([])
await expect(
gateway.prepareCustomMcpTools(excessiveToken)
).rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('工具数量超过安全限制')
})
).resolves.toEqual([])
})
it('keeps healthy tools available when another upstream fails discovery', async () => {
const healthyList = vi.fn(() => ({ tools: [testTool('healthy')] }))
const healthy = await startToolUpstream(healthyList)
const failedList = vi.fn(() => { throw new Error('private-upstream-failure') })
const failed = await startToolUpstream(failedList)
const observeFailure = vi.fn()
const gateway = new KnowledgeMcpGateway(createService().service, { observeFailure })
gateways.push(gateway)
await gateway.start()
const controller = new AbortController()
const token = gateway.grantCustomMcp('partial-discovery', [
customMcpServer(healthy.url),
customMcpServer(failed.url, '00000000-0000-4000-8000-000000000093')
], controller.signal)!
await expect(gateway.prepareCustomMcpTools(token)).resolves.toEqual([
expect.objectContaining({ name: expect.stringMatching(/_healthy$/u) })
])
const client = new Client({ name: 'partial-discovery', version: '1.0.0' })
try {
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), {
requestInit: { headers: { authorization: `Bearer ${token}` } }
}))
const listed = await client.listTools()
expect(listed.tools.map(tool => tool.name)).toEqual([
expect.stringMatching(/_healthy$/u)
])
expect(healthyList).toHaveBeenCalledTimes(1)
expect(failedList).toHaveBeenCalledTimes(1)
expect(observeFailure).toHaveBeenCalledWith(expect.objectContaining({
mcp: expect.objectContaining({ phase: 'tool-discovery' })
}))
expect(JSON.stringify(observeFailure.mock.calls)).not.toContain('private-upstream-failure')
} finally {
await client.close()
}
})
it.each(['abort', 'revoke'] as const)('does not swallow %s during discovery', async action => {
const controller = new AbortController()
const reason = new Error('request cancelled')
const gateway = new KnowledgeMcpGateway(createService().service)
gateways.push(gateway)
const upstream = await startToolUpstream(() => {
if (action === 'abort') controller.abort(reason)
else gateway.revoke(token)
return { tools: [testTool('late')] }
})
const token = gateway.grantCustomMcp('cancel-discovery', [customMcpServer(upstream.url)], controller.signal)!
const preparation = gateway.prepareCustomMcpTools(token)
if (action === 'abort') await expect(preparation).rejects.toBe(reason)
else await expect(preparation).rejects.toThrow('MCP capability was revoked')
})
it('releases rejected initialize attempts before enforcing the session limit', async () => {
@@ -1456,6 +1587,12 @@ describe('KnowledgeMcpGateway', () => {
})
await new Promise((resolve) => setTimeout(resolve, 25))
expect(listChanged).not.toHaveBeenCalled()
await expect(client.listTools()).resolves.toMatchObject({ tools: [] })
failRefresh = false
const recovered = await client.listTools()
expect(recovered.tools.map(tool => tool.name)).toEqual([
expect.stringMatching(/_stable$/u)
])
} finally {
await client.close()
}
+171 -86
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'
import { createHash, randomBytes } from 'node:crypto'
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from '../desktop-diagnostics'
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
@@ -117,6 +118,7 @@ export {
export type GoodBuddyBuiltinToolName =
| 'generate_image'
| 'save_image'
| ScopedDataToolName
| BrowserToolName
@@ -256,6 +258,7 @@ type DownstreamMcpSession = {
}
export type KnowledgeMcpGatewayOptions = {
observeFailure?: DesktopDiagnosticFailureObserver
storyGraphService?: StoryGraphService
maximumBodyBytes?: number
magicNotesDatabase?: MagicNotesDatabase
@@ -401,6 +404,7 @@ export class KnowledgeMcpGateway {
): BrowserTabUsageLease
}
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private readonly observeFailure?: DesktopDiagnosticFailureObserver
private server?: Server
private endpoint?: string
@@ -416,6 +420,18 @@ export class KnowledgeMcpGateway {
this.obsidianService = options.obsidianService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
this.observeFailure = options.observeFailure
}
private observeMcpFailure(mcp: DesktopMcpDiagnosticMetadata): void {
try {
this.observeFailure?.({
component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed',
error: new Error('Local MCP request failed'), mcp
})
} catch {
// Diagnostics must not change the HTTP response or session lifecycle.
}
}
async start(): Promise<void> {
@@ -423,7 +439,23 @@ export class KnowledgeMcpGateway {
return
}
const server = createServer((request, response) => {
void this.handleRequest(request, response).catch(() => {
const context = { phase: 'request' as DesktopMcpDiagnosticMetadata['phase'], startedAt: performance.now(), correlationId: undefined as string | undefined }
let failed = false
response.once('finish', () => {
if (!failed && response.statusCode >= 400) {
this.observeMcpFailure({
phase: context.phase, category: 'http-rejection', status: response.statusCode,
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
}
})
void this.handleRequest(request, response, context).catch(() => {
failed = true
this.observeMcpFailure({
phase: context.phase, category: 'handler-failure',
...(response.headersSent ? {} : { status: 500 }),
elapsedMs: Math.round(performance.now() - context.startedAt), correlationId: context.correlationId
})
sendJson(response, 500, {
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal server error' },
@@ -803,6 +835,7 @@ export class KnowledgeMcpGateway {
return [
...(capability.storyGraph ? storyGraphToolNames : []),
...(capability.imageToolBinding ? ['generate_image' as const] : []),
...(capability.imageToolBinding?.save ? ['save_image' as const] : []),
...(capability.libraryIds.length > 0
? knowledgeToolNames
: []),
@@ -929,6 +962,7 @@ export class KnowledgeMcpGateway {
capability.brokerController.signal
])
const changeVersion = connection.dynamicToolsChangeVersion
const startedAt = performance.now()
let refreshSucceeded = false
const refresh = (async () => {
try {
@@ -948,10 +982,15 @@ export class KnowledgeMcpGateway {
refreshSucceeded = true
await this.publishCustomMcpToolListChanged(capability)
} catch (error) {
connection.bindings = []
connection.dynamicToolsChanged = true
if (effectiveSignal.aborted) {
throw effectiveSignal.reason
}
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
elapsedMs: Math.round(performance.now() - startedAt)
})
throw new Error(
`无法刷新 MCP Server「${connection.server.name}」的工具`,
{ cause: error }
@@ -977,6 +1016,7 @@ export class KnowledgeMcpGateway {
capability: Capability,
server: ResolvedMcpServer
): Promise<CustomMcpConnection> {
const startedAt = performance.now()
let connection: CustomMcpConnection | undefined
let dynamicToolsChangeVersion = 0
const client = new Client(
@@ -1048,6 +1088,11 @@ export class KnowledgeMcpGateway {
return connection
} catch (error) {
await client.close().catch(() => undefined)
signal.throwIfAborted()
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure', kind: 'custom',
elapsedMs: Math.round(performance.now() - startedAt)
})
throw new Error(
`无法加载 MCP Server「${server.name}」的工具`,
{ cause: error }
@@ -1061,6 +1106,12 @@ export class KnowledgeMcpGateway {
refreshDynamic = true
): Promise<Map<string, CustomMcpBinding>> {
const capability = this.getCapability(token)
const effectiveSignal = AbortSignal.any([
capability.signal,
capability.brokerController.signal,
...(signal ? [signal] : [])
])
effectiveSignal.throwIfAborted()
if (capability.customMcpServers.length === 0) {
return new Map()
}
@@ -1074,15 +1125,6 @@ export class KnowledgeMcpGateway {
const connections = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
)
const failure = results.find(
(result) => result.status === 'rejected'
)
if (failure?.status === 'rejected') {
await Promise.allSettled(
connections.map((connection) => connection.client.close())
)
throw failure.reason
}
return connections
})()
}
@@ -1093,6 +1135,7 @@ export class KnowledgeMcpGateway {
capability.customMcpConnections = undefined
throw error
}
effectiveSignal.throwIfAborted()
if (refreshDynamic) {
for (const connection of connections) {
if (
@@ -1102,13 +1145,19 @@ export class KnowledgeMcpGateway {
) {
continue
}
await this.refreshDynamicTools(
capability,
connection,
signal
)
try {
await this.refreshDynamicTools(
capability,
connection,
signal
)
} catch {
// Failed catalogs are cleared by refresh; healthy servers remain usable.
effectiveSignal.throwIfAborted()
}
}
}
effectiveSignal.throwIfAborted()
const bindings = new Map<string, CustomMcpBinding>()
for (const connection of connections) {
for (const binding of connection.bindings) {
@@ -1683,76 +1732,88 @@ export class KnowledgeMcpGateway {
mcp.setRequestHandler(
ListToolsRequestSchema,
async (_request, extra) => {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
const startedAt = performance.now()
try {
const customBindings = await this.getCustomMcpBindings(
token,
extra.signal
)
const storyGraphAvailable = await this.isStoryGraphAvailable(token)
const scopedTools = [...availableTools].filter(name => !name.startsWith('story_graph_') || storyGraphAvailable).flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
const inputSchema = z.toJSONSchema(
definition.inputSchema,
{ target: 'draft-7' }
) as Tool['inputSchema'] & { $schema?: string }
Reflect.deleteProperty(inputSchema, '$schema')
return [
{
name,
title: definition.title,
description: definition.description,
inputSchema,
annotations: {
readOnlyHint: definition.access === 'read',
destructiveHint:
name === 'goodbuddy_config_apply' ||
name === 'note_delete' ||
name === 'note_entry_delete'
}
}
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const imageSaveTool = await imageSaveToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...(imageSaveTool ? [{ name: imageSaveTool.name, title: imageSaveTool.displayName, description: imageSaveTool.description, inputSchema: imageSaveTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: true } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
}
)
const capability = this.getCapability(token)
const imageTool = await imageToolDefinition(capability.imageToolBinding)
const browserTools = capability.browserConversationId && capability.browserTabId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId,
browserTabId: capability.browserTabId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...(imageTool ? [{ name: imageTool.name, description: imageTool.description, inputSchema: imageTool.inputSchema as Tool['inputSchema'], annotations: { readOnlyHint: false, destructiveHint: false } }] : []),
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
]
} catch (error) {
this.observeMcpFailure({
phase: 'tool-discovery', category: 'handler-failure',
elapsedMs: Math.round(performance.now() - startedAt),
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
throw error
}
}
)
@@ -1771,6 +1832,16 @@ export class KnowledgeMcpGateway {
const operation = await capability.imageToolBinding.call(input, `${session.registryKey}:${extra.requestId}`, AbortSignal.any([extra.signal, capability.signal]))
return { content: [{ type: 'text' as const, text: JSON.stringify(operation) }] }
}
if (name === 'save_image') {
const capability = this.getCapability(token)
if (!capability.imageToolBinding?.save) throw new Error('Image save capability is unavailable')
try {
const saved = await capability.imageToolBinding.save(input, AbortSignal.any([extra.signal, capability.signal]))
return { content: [{ type: 'text' as const, text: JSON.stringify(saved) }] }
} catch (error) {
return { isError: true, content: [{ type: 'text' as const, text: error instanceof Error ? error.message : 'Saving image failed' }] }
}
}
if (availableTools.has(name as ScopedDataToolName)) {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
@@ -1921,7 +1992,8 @@ export class KnowledgeMcpGateway {
private async handleRequest(
request: IncomingMessage,
response: ServerResponse
response: ServerResponse,
context: { phase: DesktopMcpDiagnosticMetadata['phase']; startedAt: number; correlationId?: string }
): Promise<void> {
if (request.url !== '/mcp') {
sendJson(response, 404, { error: 'Not found' })
@@ -1940,6 +2012,8 @@ export class KnowledgeMcpGateway {
})
return
}
context.phase = 'authentication'
context.startedAt = performance.now()
const authorization = request.headers.authorization
if (
typeof authorization !== 'string' ||
@@ -1949,6 +2023,7 @@ export class KnowledgeMcpGateway {
return
}
const token = authorization.slice('Bearer '.length)
context.correlationId = `sha256:${createHash('sha256').update(token).digest('hex')}`
try {
this.getCapability(token)
} catch {
@@ -1956,6 +2031,8 @@ export class KnowledgeMcpGateway {
return
}
context.phase = 'request'
context.startedAt = performance.now()
let body: unknown
if (request.method === 'POST') {
try {
@@ -1971,6 +2048,8 @@ export class KnowledgeMcpGateway {
}
}
context.phase = 'session'
context.startedAt = performance.now()
const sessionId = request.headers['mcp-session-id']
let createdSession = false
let session =
@@ -2020,15 +2099,21 @@ export class KnowledgeMcpGateway {
const availableTools = new Set(
this.getAvailableToolNames(token)
)
context.phase = 'initialize'
context.startedAt = performance.now()
session = this.createDownstreamMcpSession(
token,
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
await session.mcp.connect(session.transport)
}
try {
if (createdSession) await session.mcp.connect(session.transport)
if (body && typeof body === 'object' && 'method' in body && body.method === 'tools/list') {
context.phase = 'tool-discovery'
context.startedAt = performance.now()
}
await session.transport.handleRequest(request, response, body)
} finally {
if (createdSession && session.id === undefined) {
@@ -2052,4 +2137,4 @@ export class KnowledgeMcpGateway {
}
}
}
import { imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
import { imageSaveToolDefinition, imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
+19 -1
View File
@@ -1257,6 +1257,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
? await this.knowledgeGateway.isStoryGraphAvailable(context.knowledgeCapabilityToken) : false
const scopedTools = grantedTools.filter(tool => !tool.name.startsWith('story_graph_') || storyGraphAvailable)
const imageTool = context.workMode === 'execute' ? await imageToolDefinition(context.imageToolBinding) : undefined
const imageSaveTool = context.workMode === 'execute' ? await imageSaveToolDefinition(context.imageToolBinding) : undefined
const webTools = this.webSearchEnabled
? this.getWebSearchDefinitions()
: []
@@ -1280,6 +1281,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return [
...workspaceTools,
...(imageTool ? [imageTool] : []),
...(imageSaveTool ? [imageSaveTool] : []),
...processTools,
...subagentTools,
...outputTools,
@@ -1357,6 +1359,18 @@ export class ModelToolProvider implements ModelToolProviderLike {
allowPermanent: false
}
}
if (tool.name === 'save_image') {
return {
scopeKey: 'model:builtin:save_image',
title: '允许将图片保存到本地文件?',
description: path
? `会使用当前用户权限写入:${path}`
: '会使用当前用户权限将会话图片写入本地文件。',
toolName: tool.displayName,
argumentSummary,
allowPermanent: false
}
}
if (tool.name === 'output_read') {
return {
scopeKey: 'model:builtin:output_read',
@@ -1430,6 +1444,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
if (!context.imageToolBinding || !context.toolCallId) throw new Error('Image tool request binding is unavailable')
return createTextToolResult(JSON.stringify(await context.imageToolBinding.call(argumentsValue, context.toolCallId, signal)))
}
if (name === 'save_image') {
if (!context.imageToolBinding?.save) throw new Error('Image save capability is unavailable')
return createTextToolResult(JSON.stringify(await context.imageToolBinding.save(argumentsValue, signal)))
}
if (name === 'knowledge_list') {
if (
!this.knowledgeGateway ||
@@ -2017,4 +2035,4 @@ function emitDirectModelSubagentUsage(
)
})
}
import { imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
import { imageSaveToolDefinition, imageToolDefinition, type ImageToolBinding } from './image-tool-binding'
@@ -1,19 +1,347 @@
// @vitest-environment node
import { existsSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { createServer, request as httpRequest } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { expect, it } from 'vitest'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { OpenCodeRuntime } from './opencode-runtime'
import type { DesktopDiagnosticFailure } from '../desktop-diagnostics'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
it.skipIf(!existsSync(binaryPath))(
'completes Execute with the real OpenCode binary when an assigned custom MCP returns HTTP 503',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-unavailable-'))
let modelCalls = 0
const model = createServer((_request, response) => {
modelCalls++
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'unavailable', object: 'chat.completion.chunk', created: 1, model: 'unavailable',
choices: [{ index: 0, delta: { role: 'assistant', content: 'MCP_UNAVAILABLE_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
let mcpRequests = 0
const failureBody = 'private-mcp-failure-detail'
const upstream = createServer((_request, response) => {
mcpRequests++
response.writeHead(503, { Connection: 'close' })
response.end(failureBody)
})
const records: DesktopDiagnosticFailure[] = []
const gateway = new KnowledgeMcpGateway({} as never, {
observeFailure: failure => records.push(failure)
})
const controller = new AbortController()
const deadline = setTimeout(() => controller.abort(), 30_000)
let runtime: OpenCodeRuntime | undefined
try {
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
await new Promise<void>(resolve => upstream.listen(0, '127.0.0.1', resolve))
const modelAddress = model.address()
const mcpAddress = upstream.address()
if (!modelAddress || typeof modelAddress === 'string') throw new Error('No model port')
if (!mcpAddress || typeof mcpAddress === 'string') throw new Error('No MCP port')
await gateway.start()
const secret = 'private-mcp-fixture-secret'
const url = `http://127.0.0.1:${mcpAddress.port}/mcp`
runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
mcpServers: [{
id: crypto.randomUUID(), name: 'Unavailable MCP', description: '',
enabled: true, allowDynamicTools: false, assignments: ['opencode'],
secretConfigured: true, secret, transport: 'http', url
}],
modelProfile: {
id: crypto.randomUUID(), name: 'Unavailable MCP fixture', modelName: 'unavailable',
baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
})
let text = ''
let lastEventType: string | undefined
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), workMode: 'execute',
prompt: 'Reply MCP_UNAVAILABLE_OK without tools.'
}, controller.signal)) {
if (event.type === 'text') text += event.delta
lastEventType = event.type
}
expect(mcpRequests).toBeGreaterThan(0)
expect(text).toBe('MCP_UNAVAILABLE_OK')
expect(lastEventType).toBe('done')
expect(modelCalls).toBe(1)
expect(records).toHaveLength(1)
expect(records[0]).toMatchObject({
code: 'runtime.mcp.failed',
mcp: { phase: 'tool-discovery', category: 'handler-failure' }
})
const diagnostics = JSON.stringify(records, (_key, value) => value instanceof Error
? { message: value.message, stack: value.stack, cause: value.cause }
: value)
expect(diagnostics).not.toContain(secret)
expect(diagnostics).not.toContain(url)
expect(diagnostics).not.toContain(failureBody)
} finally {
clearTimeout(deadline)
await runtime?.dispose()
await gateway.dispose()
upstream.closeAllConnections()
model.closeAllConnections()
await new Promise<void>(resolve => upstream.close(() => resolve()))
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
60_000
)
it.skipIf(!existsSync(binaryPath))(
'recovers a lost MCP add response with the real OpenCode binary and gateway',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-retry-'))
let modelCalls = 0
const model = createServer((_request, response) => {
modelCalls++
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'retry', object: 'chat.completion.chunk', created: 1, model: 'retry',
choices: [{ index: 0, delta: { role: 'assistant', content: 'RETRY_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
if (!address || typeof address === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const token = gateway.grant(requestId, [crypto.randomUUID()], controller.signal)!
const records: DesktopDiagnosticFailure[] = []
const operations: string[] = []
const registrations: Array<{ name: string; config: { headers: { Authorization: string } } }> = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: gateway,
observeFailure: failure => records.push(failure),
modelProfile: {
id: crypto.randomUUID(), name: 'Retry fixture', modelName: 'retry',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
createClient: options => createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const pathname = new URL(request.url).pathname
if (pathname === '/mcp' && request.method === 'POST') {
const registration = await request.clone().json() as typeof registrations[number]
registrations.push(registration)
const response = await fetch(request, init)
const status = await response.clone().json() as Record<string, { status: string }>
expect(status[registration.name]?.status).toBe('connected')
operations.push('add-connected')
if (registrations.length === 1) {
// The server and gateway are already connected when the response is lost.
await response.body?.cancel()
throw new TypeError('fetch failed', { cause: Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) })
}
return response
}
const response = await fetch(request, init)
if (pathname.endsWith('/disconnect')) {
expect(await response.clone().json()).toBe(true)
operations.push('disconnect-succeeded')
}
return response
}
})
})
const deadline = setTimeout(() => controller.abort(), 30_000)
try {
let text = ''
for await (const event of runtime.run({
requestId, conversationId: crypto.randomUUID(), workMode: 'ask',
prompt: 'Reply RETRY_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
expect(text).toBe('RETRY_OK')
expect(modelCalls).toBe(1)
expect(operations).toEqual(['add-connected', 'disconnect-succeeded', 'add-connected', 'disconnect-succeeded'])
expect(registrations).toHaveLength(2)
expect(registrations[1]?.name).not.toBe(registrations[0]?.name)
expect(registrations[1]?.config).toEqual(registrations[0]?.config)
expect(registrations[0]?.config.headers.Authorization).toBe(`Bearer ${token}`)
expect(records).toHaveLength(1)
expect(records[0]?.mcp).toMatchObject({
phase: 'connect', category: 'transport-failure', attempt: 1, kind: 'builtin',
correlationId: `sha256:${createHash('sha256').update(token).digest('hex')}`
})
expect(JSON.stringify(records)).not.toContain(token)
const unauthorized = await fetch(gateway.getEndpoint()!, { method: 'POST' })
expect(unauthorized.status).toBe(401)
await unauthorized.body?.cancel()
} finally {
clearTimeout(deadline)
await runtime.dispose()
gateway.revoke(token)
await gateway.dispose()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
60_000
)
it.skipIf(!existsSync(binaryPath))(
'keeps a later MCP connection when a timed-out OpenCode add settles afterwards',
async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-mcp-late-add-'))
let releaseModel!: () => void
const modelReleased = new Promise<void>(resolve => { releaseModel = resolve })
let modelStarted!: () => void
const modelPending = new Promise<void>(resolve => { modelStarted = resolve })
const model = createServer((_request, response) => {
modelStarted()
void modelReleased.then(() => {
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(`data: ${JSON.stringify({
id: 'late', object: 'chat.completion.chunk', created: 1, model: 'late',
choices: [{ index: 0, delta: { role: 'assistant', content: 'LATE_OK' }, finish_reason: 'stop' }]
})}\n\ndata: [DONE]\n\n`)
})
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const modelAddress = model.address()
if (!modelAddress || typeof modelAddress === 'string') throw new Error('No model port')
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
const upstream = new URL(gateway.getEndpoint()!)
// Holds the first MCP initialize so OpenCode keeps initializing after the client gives up.
let releaseInitialize!: () => void
const initializeReleased = new Promise<void>(resolve => { releaseInitialize = resolve })
let heldInitializeSettled!: () => void
const heldInitializeDone = new Promise<void>(resolve => { heldInitializeSettled = resolve })
let heldInitialize = false
const proxy = createServer((request, response) => {
void (async () => {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(chunk as Buffer)
const body = Buffer.concat(chunks)
const hold = !heldInitialize && request.method === 'POST' && body.toString().includes('"initialize"')
if (hold) {
heldInitialize = true
await initializeReleased
}
const forwarded = httpRequest({
host: upstream.hostname, port: upstream.port, path: request.url,
method: request.method, headers: request.headers
}, upstreamResponse => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
upstreamResponse.pipe(response)
if (hold) upstreamResponse.on('end', heldInitializeSettled)
})
forwarded.on('error', () => response.destroy())
response.on('close', () => forwarded.destroy())
forwarded.end(body)
})().catch(() => response.destroy())
})
await new Promise<void>(resolve => proxy.listen(0, '127.0.0.1', resolve))
const proxyAddress = proxy.address()
if (!proxyAddress || typeof proxyAddress === 'string') throw new Error('No proxy port')
const proxiedGateway = new Proxy(gateway, {
get: (target, key, receiver) => key === 'getEndpoint'
? () => `http://127.0.0.1:${proxyAddress.port}/mcp`
: Reflect.get(target, key, receiver)
})
let client: ReturnType<typeof createOpencodeClient> | undefined
const added: string[] = []
const runtime = new OpenCodeRuntime({
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath,
configPath: '', defaultWorkspace: workspace, knowledgeGateway: proxiedGateway,
modelProfile: {
id: crypto.randomUUID(), name: 'Late add fixture', modelName: 'late',
baseUrl: `http://127.0.0.1:${modelAddress.port}/v1`,
protocol: 'openai-chat-completions', authentication: 'none'
}
}, {
controlRequestTimeoutMs: 3_000,
createClient: options => {
client = createOpencodeClient({
...options,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (new URL(request.url).pathname === '/mcp' && request.method === 'POST') {
added.push((await request.clone().json() as { name: string }).name)
}
return fetch(request, init)
}
})
return client
}
})
const conversationId = crypto.randomUUID()
const run = async (): Promise<string> => {
const controller = new AbortController()
const token = gateway.grant(crypto.randomUUID(), [crypto.randomUUID()], controller.signal)!
const deadline = setTimeout(() => controller.abort(), 30_000)
let text = ''
try {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId, workMode: 'ask',
prompt: 'Reply LATE_OK without tools.', knowledgeCapabilityToken: token
}, controller.signal)) {
if (event.type === 'text') text += event.delta
}
return text
} finally {
clearTimeout(deadline)
gateway.revoke(token)
}
}
try {
await expect(run()).rejects.toThrow('超时')
expect(added).toHaveLength(1)
const second = run()
await modelPending
expect(added).toHaveLength(2)
expect((await client!.mcp.status({ directory: workspace })).data?.[added[1]!]?.status).toBe('connected')
// The late add now finishes with a revoked token while the second request is live.
releaseInitialize()
await heldInitializeDone
await new Promise(resolve => setTimeout(resolve, 500))
const status = await client!.mcp.status({ directory: workspace })
expect(status.data?.[added[1]!]?.status).toBe('connected')
expect(added[1]).not.toBe(added[0])
releaseModel()
await expect(second).resolves.toBe('LATE_OK')
} finally {
releaseInitialize()
releaseModel()
await runtime.dispose()
await gateway.dispose()
proxy.closeAllConnections()
model.closeAllConnections()
await new Promise<void>(resolve => proxy.close(() => resolve()))
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(workspace, { recursive: true, force: true })
}
},
90_000
)
it.skipIf(!existsSync(binaryPath))(
'closes real event streams after parallel runs and compaction without cancelling a peer',
async () => {
+474 -12
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import {
mkdir,
@@ -11,11 +12,12 @@ import {
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { PassThrough } from "node:stream";
import type { createOpencodeClient } from "@opencode-ai/sdk/v2";
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import type spawn from "cross-spawn";
import { describe, expect, it, vi } from "vitest";
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
import type { RuntimeEvent } from "./runtime";
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
import {
createTemporaryMcpName,
OpenCodeRuntime,
@@ -238,6 +240,7 @@ function runClient(events: Record<string, unknown>[]) {
[input.name]: { status: "connected" },
},
error: undefined,
response: { status: 200 },
})),
disconnect: vi.fn().mockResolvedValue({ data: true, error: undefined }),
},
@@ -3220,6 +3223,388 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it.each(["knowledge", "custom"].flatMap<{
kind: string; scenario: string; detail: string; attempts: number;
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
}>((kind) => [
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
const secret = "request-private-capability";
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
const fail = async (input?: { name?: string }) => {
if (thrown) throw new Error(failure);
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
return { response: { status: 200 }, data: status === "missing" ? {} : {
[input!.name!]: { status: status ?? "failed", error: failure },
} };
};
// The SDK returns a map keyed by the registered name, not a top-level status.
if (succeeds) add.mockImplementationOnce(fail as never);
else add.mockImplementation(fail as never);
const gateway = {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
grantCustomMcp: vi.fn(() => secret),
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
revoke: vi.fn(),
};
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
mcpServers: kind === "custom" ? [{
id: "custom", name: "Custom", description: "", transport: "http",
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
assignments: ["opencode"], secretConfigured: false,
}] : [],
});
const collect = async () => {
const events: RuntimeEvent[] = [];
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
workMode: kind === "custom" ? "execute" : "ask",
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
}, new AbortController().signal)) events.push(event);
return events;
};
try {
if (succeeds) {
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(detail);
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
expect(add).toHaveBeenCalledTimes(attempts);
expect(disconnect).toHaveBeenCalledTimes(attempts);
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
expect(record).toMatchObject({
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
mcp: {
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
elapsedMs: expect.any(Number),
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
...(apiError ? { status: 403 } : {}),
},
});
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
expect((record.error as Error).message).toBe("Local MCP operation failed");
}
if (attempts === 2) {
expect(add.mock.calls[1]![0]?.config).toEqual(add.mock.calls[0]![0]?.config);
if (thrown) expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
else expect(add.mock.calls[1]![0]?.name).toBe(add.mock.calls[0]![0]?.name);
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
}
if (succeeds) {
const name = add.mock.calls.at(-1)![0]!.name;
const toolId = `${name}_${kind === "custom" ? "custom_tool" : "knowledge_search"}`;
expect(setup.session.create).toHaveBeenCalledWith(expect.objectContaining({
permission: expect.arrayContaining([{ permission: toolId, pattern: "*", action: "allow" }]),
}), expect.anything());
expect(setup.session.promptAsync).toHaveBeenCalledWith(expect.objectContaining({
tools: expect.objectContaining({ [toolId]: true }),
}), expect.anything());
}
expect(add.mock.calls[0]![0]?.config).toMatchObject({
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
});
if (kind === "custom") {
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
}
} finally {
await runtime.dispose();
}
});
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
"handles installed SDK fetch results: %s", async (scenario) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-sdk-capability";
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
// A circular cause must not cause unbounded traversal or expose a stack.
Object.assign(cause, { cause });
const failure = new TypeError("fetch failed", { cause });
const order: string[] = [];
const signals: AbortSignal[] = [];
let adds = 0;
const sdk = createOpencodeClient({
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
if (new URL(request.url).pathname.endsWith("/disconnect")) {
order.push("disconnect");
signals.push(request.signal);
if (scenario === "cleanup-transport") throw failure;
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
return Response.json(scenario !== "cleanup-false");
}
order.push("add");
adds++;
if (scenario.startsWith("http-")) {
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
}
if (scenario === "unknown") throw new TypeError("fetch failed");
if (adds === 1 || scenario === "exhausted") throw failure;
const { name } = await request.json() as { name: string };
return Response.json({ [name]: { status: "connected" } });
},
});
Object.assign(setup.client, { mcp: sdk.mcp });
const add = vi.spyOn(sdk.mcp, "add");
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async () => {
for await (const event of runtime.run({
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
};
try {
if (scenario === "recovered") {
await collect();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} else {
const error = await collect().catch((error: unknown) => error) as Error;
expect(error).toBeInstanceOf(Error);
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
expect(error.message.length).toBeLessThan(2_100);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
}
const first = await add.mock.results[0]!.value;
if (scenario.startsWith("http-")) {
expect(first.response.status).toBe(Number(scenario.slice(5)));
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
} else {
expect(first.response).toBeUndefined();
expect(first.error).toBeInstanceOf(TypeError);
if (scenario !== "unknown") expect(first.error).toBe(failure);
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
}
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
if (add.mock.calls.length === 2) {
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
}
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
? ["add", "disconnect", "add", "disconnect"]
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
const records = observeFailure.mock.calls.map(([record]) => record);
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
} finally {
await runtime.dispose();
}
},
);
it("persists a recovered MCP attempt even when the observer throws", async () => {
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
const diagnostics = new DesktopDiagnostics(directory);
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const token = "private-correlation-token";
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
})) as never);
const writes: Promise<void>[] = [];
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
writes.push(diagnostics.recordFailure(failure));
throw new Error("observer failed");
});
const runtime = embeddedRuntime(setup.client, {
observeFailure: observer,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: token,
}, new AbortController().signal)) void event;
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
expect(observer).toHaveBeenCalledOnce();
await Promise.all(writes);
await diagnostics.dispose();
const restarted = new DesktopDiagnostics(directory);
try {
const records = await restarted.readRecent();
expect(records).toHaveLength(1);
expect(records[0]?.mcp).toMatchObject({
phase: "connect", attempt: 1, kind: "builtin",
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
});
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
} finally { await restarted.dispose(); }
} finally {
await runtime.dispose();
await diagnostics.dispose();
await rm(directory, { recursive: true, force: true });
}
});
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the name after MCP cleanup %s", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async (input: { name: string }) => ({
response: { status: 200 },
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
})) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
const runtime = embeddedRuntime(setup.client, {
observeFailure,
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const collect = async (requestId: string) => {
for await (const event of runtime.run({
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: requestId,
}, new AbortController().signal)) void event;
};
try {
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
expect(add).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).not.toHaveBeenCalled();
expect(observeFailure).toHaveBeenCalledTimes(3);
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
]);
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
disconnect.mockResolvedValue({ data: true } as never);
await collect("second");
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
await collect("third");
expect(add.mock.calls[2]![0]?.name).toBe(add.mock.calls[1]![0]?.name);
} finally {
await runtime.dispose();
}
});
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
const setup = runClient([]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementation((async (input: { name: string }) => {
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
controller.abort(new Error("cancel initialization"));
}
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
}) as never);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockImplementation((async () => {
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
return { data: true };
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, controller.signal)) void event;
})()).rejects.toThrow("cancel initialization");
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
expect(setup.session.promptAsync).not.toHaveBeenCalled();
} finally {
await runtime.dispose();
}
});
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
const setup = runClient([
{
id: "tool-error", type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
state: {
status: "error", input: {}, error: "connect ECONNRESET",
time: { start: 1, end: 2 },
},
},
},
},
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
try {
const events: RuntimeEvent[] = [];
await expect((async () => {
for await (const event of runtime.run({
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "private-token",
}, new AbortController().signal)) events.push(event);
})()).rejects.toThrow("connect ECONNRESET");
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
} finally {
await runtime.dispose();
}
});
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
@@ -3303,12 +3688,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
);
const base = names[0]!;
expect(base).toMatch(/^gbd-[a-f0-9]{6}$/u);
// request-1 failed to disconnect (rejected) -> slot 1 stays leased.
expect(names[1]).toBe(`${base}-2`);
// request-2 disconnect returned an error -> slot 2 stays leased.
expect(names[2]).toBe(`${base}-3`);
// request-3 disconnected cleanly -> slot 3 is reusable.
expect(names[3]).toBe(`${base}-3`);
expect(new Set(names.slice(0, 3)).size).toBe(3);
expect(names.every(name => name.length <= 12)).toBe(true);
// Successful cleanup preserves the replacement name for the same slot.
expect(names[3]).toBe(names[2]);
} finally {
await runtime.dispose();
}
@@ -3335,6 +3718,81 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
expect(runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toBe(4);
expect(createTemporaryMcpName("gbd-", "abc123", 1)).toBe("gbd-abc123");
expect(createTemporaryMcpName("gbc-", "abc123", 4)).toBe("gbc-abc123-4");
for (const generation of [1, 35, 36, 36 ** 8 - 1]) {
const name = createTemporaryMcpName("gbc-", "abc123", 9, generation);
expect(`${name}_${"x".repeat(51)}`).toHaveLength(64);
expect(name).not.toBe(createTemporaryMcpName("gbc-", "abc123", 9));
}
expect(() => createTemporaryMcpName("gbc-", "abc123", 9, 36 ** 8)).toThrow("generations exhausted");
});
it.each(["throw", "no-response", "timeout", "abort"])("retires an uncertain MCP add after %s despite successful disconnect", async (failure) => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const controller = new AbortController();
const add = vi.mocked(setup.client.mcp.add);
add.mockImplementationOnce((async () => {
if (failure === "throw") throw new Error("unknown initialization failure");
if (failure === "no-response") return { error: new Error("unknown initialization failure") };
if (failure === "abort") controller.abort(new Error("cancel initialization"));
return new Promise(() => {});
}) as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
}, { controlRequestTimeoutMs: 50 });
const run = async (signal: AbortSignal) => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId: "uncertain", prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "token",
}, signal)) void event;
};
try {
await expect(run(controller.signal)).rejects.toThrow();
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
await run(new AbortController().signal);
await run(new AbortController().signal);
const names = add.mock.calls.map(([input]) => input!.name);
expect(names[1]).not.toBe(names[0]);
expect(names[2]).toBe(names[1]);
expect(vi.mocked(setup.client.mcp.disconnect).mock.calls.map(([input]) => input!.name)).toEqual(names);
} finally { await runtime.dispose(); }
});
it("releases active slots after more than nine failed cleanups and isolates replacement generations", async () => {
const setup = runClient([
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
]);
const disconnect = vi.mocked(setup.client.mcp.disconnect);
disconnect.mockResolvedValue({ data: false } as never);
const runtime = embeddedRuntime(setup.client, {
knowledgeGateway: {
getEndpoint: () => "http://127.0.0.1:4567/mcp",
getAvailableToolNames: () => ["knowledge_search"],
} as unknown as KnowledgeMcpGateway,
});
const run = async (conversationId: string) => {
for await (const event of runtime.run({
requestId: crypto.randomUUID(), conversationId, prompt: "test", workMode: "ask",
knowledgeCapabilityToken: "token",
}, new AbortController().signal)) void event;
};
try {
for (let i = 0; i < 12; i++) await run("cleanup-failures");
await run("other-conversation");
disconnect.mockResolvedValue({ data: true } as never);
await run("other-conversation");
await run("cleanup-failures");
await run("cleanup-failures");
const names = vi.mocked(setup.client.mcp.add).mock.calls.map(([input]) => input!.name);
expect(new Set(names.slice(0, 15)).size).toBe(15);
expect(names[15]).toBe(names[14]);
expect(names.every(name => name !== undefined && name.length <= 12)).toBe(true);
expect(disconnect.mock.calls.map(([input]) => input!.name)).toEqual(names);
} finally { await runtime.dispose(); }
});
it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => {
@@ -3400,7 +3858,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("runs embedded conversations concurrently while serializing MCP registrations", async () => {
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
const setup = runClient([
{
id: "idle",
@@ -3437,7 +3895,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await firstAdd;
return {
data: {
[input.name]: { status: "connected" },
[input.name]: retry
? { status: "failed", error: "connect ECONNRESET" }
: { status: "connected" },
},
error: undefined,
};
@@ -3500,7 +3960,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
} finally {
resolveFirstAdd();
}
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
await Promise.all([first, second]);
expect(
mcpAdd.mock.calls.map(
@@ -3511,8 +3971,10 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
}
).config.headers.Authorization,
),
).toEqual(["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2);
).toEqual(retry
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
: ["Bearer first-token", "Bearer second-token"]);
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
await runtime.dispose();
});
+145 -115
View File
@@ -56,7 +56,8 @@ import {
runtimePrivacyEnvironment,
} from "./process-environment";
import type { LaunchEnvironmentProvider } from "../local-tool-environment/launch-environment-provider";
import { boundedToolDetail, safeToolErrorDetail } from "./approval-summary";
import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } from "../desktop-diagnostics";
import { boundedToolDetail, redactSensitiveText, safeToolErrorDetail } from "./approval-summary";
import type {
ResolvedMcpServer,
RuntimeSkillPackage,
@@ -503,6 +504,7 @@ export type OpenCodeRuntimeDependencies = {
};
export type OpenCodeRuntimeOptions = {
observeFailure?: DesktopDiagnosticFailureObserver;
baseUrl?: string;
embedded: boolean;
binaryPath: string;
@@ -551,7 +553,13 @@ export function createTemporaryMcpName(
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
conversationKey: string,
slot: number,
generation = 0,
): string {
if (generation > 0) {
// Eight base-36 digits keep the registration within the 12-character budget.
if (generation >= 36 ** 8) throw new Error("OpenCode MCP name generations exhausted");
return `${prefix}${generation.toString(36).padStart(8, "0")}`;
}
return `${prefix}${conversationKey}${slot === 1 ? "" : `-${slot}`}`;
}
@@ -774,14 +782,11 @@ export class OpenCodeRuntime implements AgentRuntime {
>();
private readonly conversationRunTails = new Map<string, Promise<void>>();
private mcpMutationTail: Promise<void> = Promise.resolve();
// Temporary MCP name slots per OpenCode client and conversation. A slot is
// held while a request owns its MCP connections and is only freed after
// every disconnect succeeds, so a request never reuses a name that may still
// be bound to another request's capability token. A new client (restarted
// server) starts with a clean MCP registry and therefore fresh slots.
// Slots bound active requests; generations retire uncertain names without
// retaining a slot forever. Replacement names are unique across this client.
private readonly temporaryMcpSlots = new WeakMap<
OpencodeClient,
Map<string, Set<number>>
{ generation: number; conversations: Map<string, Map<number, { active: boolean; generation: number }>> }
>();
private readonly dependencies: OpenCodeRuntimeDependencies;
@@ -863,19 +868,20 @@ export class OpenCodeRuntime implements AgentRuntime {
client: OpencodeClient,
conversationKey: string,
): number {
let byConversation = this.temporaryMcpSlots.get(client);
if (!byConversation) {
byConversation = new Map();
this.temporaryMcpSlots.set(client, byConversation);
let state = this.temporaryMcpSlots.get(client);
if (!state) {
state = { generation: 0, conversations: new Map() };
this.temporaryMcpSlots.set(client, state);
}
let occupied = byConversation.get(conversationKey);
let occupied = state.conversations.get(conversationKey);
if (!occupied) {
occupied = new Set();
byConversation.set(conversationKey, occupied);
occupied = new Map();
state.conversations.set(conversationKey, occupied);
}
for (let slot = 1; slot <= MAX_TEMPORARY_MCP_SLOTS; slot += 1) {
if (!occupied.has(slot)) {
occupied.add(slot);
const entry = occupied.get(slot);
if (!entry?.active) {
occupied.set(slot, { active: true, generation: entry?.generation ?? 0 });
return slot;
}
}
@@ -889,12 +895,14 @@ export class OpenCodeRuntime implements AgentRuntime {
conversationKey: string,
slot: number,
): void {
const byConversation = this.temporaryMcpSlots.get(client);
const byConversation = this.temporaryMcpSlots.get(client)?.conversations;
const occupied = byConversation?.get(conversationKey);
if (!byConversation || !occupied) {
return;
}
occupied.delete(slot);
const entry = occupied.get(slot);
if (entry?.generation) entry.active = false;
else occupied.delete(slot);
if (occupied.size === 0) {
byConversation.delete(conversationKey);
}
@@ -2239,7 +2247,8 @@ export class OpenCodeRuntime implements AgentRuntime {
let knowledgeToolIds: string[] = [];
let customMcpName: string | undefined;
let customMcpToken: string | undefined;
const attemptedMcpNames: string[] = [];
type McpAttempt = Pick<DesktopMcpDiagnosticMetadata, "correlationId" | "kind" | "attempt">;
const attemptedMcpNames = new Map<string, McpAttempt>();
const mcpConversationKey = temporaryMcpConversationKey(
request.conversationId,
);
@@ -2248,13 +2257,123 @@ export class OpenCodeRuntime implements AgentRuntime {
prefix: (typeof TEMPORARY_MCP_PREFIXES)[number],
): string => {
mcpSlot ??= this.acquireTemporaryMcpSlot(client, mcpConversationKey);
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot);
const entry = this.temporaryMcpSlots.get(client)!.conversations.get(mcpConversationKey)!.get(mcpSlot)!;
return createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot, entry.generation);
};
const retireMcpName = (name: string): void => {
const state = this.temporaryMcpSlots.get(client)!;
const entry = state.conversations.get(mcpConversationKey)!.get(mcpSlot!)!;
const prefix = name.startsWith(KNOWLEDGE_MCP_PREFIX) ? KNOWLEDGE_MCP_PREFIX : CUSTOM_MCP_PREFIX;
// A late cleanup of an already retired name must not retire its successor.
if (createTemporaryMcpName(prefix, mcpConversationKey, mcpSlot!, entry.generation) === name) {
entry.generation = ++state.generation;
}
};
const subscriptionController = new AbortController();
const imageCapabilityToken = request.imageToolBinding && request.workMode === 'execute'
? this.options.knowledgeGateway?.bindImageTool(request.imageToolBinding, signal, request.knowledgeCapabilityToken)
: undefined;
const scopedCapabilityToken = imageCapabilityToken ?? request.knowledgeCapabilityToken;
const observeMcpFailure = (mcp: DesktopMcpDiagnosticMetadata): void => {
try {
this.options.observeFailure?.({
component: "runtime", stage: mcp.phase, code: "runtime.mcp.failed",
error: new Error("Local MCP operation failed"), mcp,
});
} catch {
// Diagnostics must not affect retry, cancellation, or cleanup.
}
};
const disconnectMcp = async (name: string, metadata: McpAttempt): Promise<boolean> => {
// Called under the mutation lock; queue wait does not consume this budget.
const cleanupSignal = AbortSignal.timeout(1_000);
const startedAt = performance.now();
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let status: number | undefined;
try {
const result = await awaitWithAbort(
client.mcp.disconnect({ name, directory }, { signal: cleanupSignal }),
cleanupSignal,
);
if (result.data === true && !result.error) return true;
status = result.response?.status;
category = result.error && !result.response ? "transport-failure"
: status && status >= 400 ? "http-rejection" : "cleanup-failure";
} catch {
// The name is retired below; the active request can still release its slot.
}
retireMcpName(name);
observeMcpFailure({ ...metadata, phase: "disconnect", category, status,
elapsedMs: Math.round(performance.now() - startedAt) });
return false;
};
const addMcp = (prefix: (typeof TEMPORARY_MCP_PREFIXES)[number], url: string, token: string, label: string) =>
this.mutateMcp(signal, async () => {
let name = temporaryMcpName(prefix);
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted();
const startedAt = performance.now();
const metadata: McpAttempt = {
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
kind: name.startsWith(KNOWLEDGE_MCP_PREFIX) ? "builtin" : "custom",
attempt: attempt === 0 ? 1 : 2,
};
let failure: unknown;
let retryableStatus = true;
let category: DesktopMcpDiagnosticMetadata["category"] = "transport-failure";
let httpStatus: number | undefined;
let attempted = false;
let confirmed = false;
try {
const added = await this.controlRequest(label, (controlSignal) => {
attemptedMcpNames.set(name, metadata);
attempted = true;
return client.mcp.add({
directory, name,
config: {
type: "remote", url, enabled: true,
headers: { Authorization: `Bearer ${token}` },
oauth: false,
},
}, { signal: controlSignal });
}, signal);
confirmed = !!added.response;
const status = added.data?.[name];
httpStatus = added.response?.status;
const httpRejected = httpStatus !== undefined && httpStatus >= 400;
if (!httpRejected && !added.error && status?.status === "connected") return name;
// With throwOnError:false the SDK resolves fetch failures without a response.
const transportFailure = !!added.error && !added.response;
category = transportFailure ? "transport-failure"
: httpRejected ? "http-rejection" : "initialization-failure";
retryableStatus = transportFailure || (!httpRejected && !added.error && status?.status === "failed");
failure = added.error ?? (status && "error" in status
? `${status.status}: ${status.error}`
: status?.status ?? "unknown");
} catch (error) {
failure = error;
} finally {
// Aborting the HTTP client does not cancel OpenCode's pending add.
if (attempted && !confirmed) retireMcpName(name);
}
observeMcpFailure({ ...metadata, phase: "connect",
category: signal.aborted ? "cancelled" : category, status: httpStatus,
elapsedMs: Math.round(performance.now() - startedAt) });
signal.throwIfAborted();
const failureDetail = safeToolErrorDetail(failure) ?? "unknown";
const detail = redactSensitiveText(
failureDetail.split(token).join("[REDACTED]"),
).replace(/https?:\/\/[^\s<>"']+/giu, "[URL]");
const error = new Error(`OpenCode ${label}失败(${httpStatus && httpStatus >= 400 ? `HTTP ${httpStatus}: ` : ""}${detail})`);
// Only explicit transport failures during initialization may be retried.
const transient = /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b|\b(?:connection closed|socket hang up|request timed out)\b/iu.test(detail)
&& !/\b(?:auth(?:entication|orization)?\s+(?:failed|required|error|denied)|oauth|credentials|unauthorized|forbidden|401|403|protocol|invalid|parse|json|method)\b/iu.test(failureDetail);
if (attempt > 0 || !retryableStatus || !transient) throw error;
if (!await disconnectMcp(name, metadata)) throw error;
attemptedMcpNames.delete(name);
name = temporaryMcpName(prefix);
}
});
try {
if (
scopedCapabilityToken &&
@@ -2262,43 +2381,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.options.knowledgeGateway?.getEndpoint()
) {
const knowledgeEndpoint = this.options.knowledgeGateway.getEndpoint()!;
knowledgeMcpName = temporaryMcpName(KNOWLEDGE_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接内置只读工具",
(controlSignal) => {
attemptedMcpNames.push(knowledgeMcpName!);
return client.mcp.add(
{
directory,
name: knowledgeMcpName,
config: {
type: "remote",
url: knowledgeEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${scopedCapabilityToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
if (added.error || !added.data) {
throw new Error(
opencodeErrorMessage(added.error, "OpenCode 内置只读工具连接失败"),
);
}
const addedStatus = added.data[knowledgeMcpName];
if (!addedStatus || addedStatus.status !== "connected") {
throw new Error(
`OpenCode 内置只读工具连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
knowledgeMcpName = await addMcp(KNOWLEDGE_MCP_PREFIX, knowledgeEndpoint, scopedCapabilityToken, "连接内置只读工具");
// OpenCode 1.18.x does not include dynamically added MCP tools in
// experimental/tool/ids. Its model tool namespace is deterministic:
// "<MCP server name>_<declared tool name>".
@@ -2324,43 +2407,7 @@ export class OpenCodeRuntime implements AgentRuntime {
customMcpToken,
signal,
);
customMcpName = temporaryMcpName(CUSTOM_MCP_PREFIX);
const added = await this.mutateMcp(signal, () =>
this.controlRequest(
"连接自定义 MCP 工具",
(controlSignal) => {
attemptedMcpNames.push(customMcpName!);
return client.mcp.add(
{
directory,
name: customMcpName,
config: {
type: "remote",
url: customMcpEndpoint,
enabled: true,
headers: {
Authorization: `Bearer ${customMcpToken}`,
},
oauth: false,
},
},
{ signal: controlSignal },
);
},
signal,
),
);
const addedStatus = added.data?.[customMcpName];
if (
added.error ||
!added.data ||
!addedStatus ||
addedStatus.status !== "connected"
) {
throw new Error(
`OpenCode 自定义 MCP 连接失败(${addedStatus?.status ?? "unknown"})`,
);
}
customMcpName = await addMcp(CUSTOM_MCP_PREFIX, customMcpEndpoint, customMcpToken, "连接自定义 MCP 工具");
knowledgeToolIds.push(
...tools.map((tool) => `${customMcpName}_${tool.name}`),
);
@@ -3192,30 +3239,13 @@ export class OpenCodeRuntime implements AgentRuntime {
} finally {
subscriptionController.abort();
if (imageCapabilityToken && imageCapabilityToken !== request.knowledgeCapabilityToken) this.options.knowledgeGateway?.revoke(imageCapabilityToken);
let allMcpDisconnected = true;
for (const name of attemptedMcpNames) {
const disconnected = await this.mutateMcp(
for (const [name, metadata] of attemptedMcpNames) {
await this.mutateMcp(
new AbortController().signal,
() => {
// Queue wait must not consume the disconnect timeout.
const cleanupSignal = AbortSignal.timeout(1_000);
return awaitWithAbort(
client.mcp.disconnect(
{ name, directory },
{ signal: cleanupSignal },
),
cleanupSignal,
);
},
).then(
(result) => !(result as { error?: unknown } | undefined)?.error,
() => false,
() => disconnectMcp(name, metadata),
);
allMcpDisconnected &&= disconnected;
}
// A slot whose connection may still be alive stays occupied so a later
// request cannot reuse a name bound to this request's token.
if (mcpSlot !== undefined && allMcpDisconnected) {
if (mcpSlot !== undefined) {
this.releaseTemporaryMcpSlot(client, mcpConversationKey, mcpSlot);
}
if (customMcpToken) {
+62 -3
View File
@@ -44,6 +44,65 @@ afterEach(async () => {
})
describe('ApplicationSettingsStore', () => {
it.each([11, 12])('defaults missing device sharing to off and preserves explicit choices in version %s', async (version) => {
for (const deviceSharingEnabled of [undefined, false, true]) {
const { filePath, store } = await createStore()
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, version,
lastSeenReleaseNotesVersion: null, deviceSharingEnabled, checkUpdatesOnStartup: false }))
expect(await store.get()).toMatchObject({ deviceSharingEnabled: deviceSharingEnabled ?? false, checkUpdatesOnStartup: false })
await store.update({ magicNotesEnabled: false })
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled ?? false)
}
})
it('persists and publishes device sharing toggles without resetting them on unrelated updates', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).deviceSharingEnabled).toBe(false)
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, deviceSharingEnabled: undefined }).deviceSharingEnabled).toBe(false)
const changed = vi.fn()
store.onChanged(changed)
for (const deviceSharingEnabled of [true, false]) {
const saved = await store.update({ deviceSharingEnabled })
expect(saved.deviceSharingEnabled).toBe(deviceSharingEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).deviceSharingEnabled).toBe(deviceSharingEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).deviceSharingEnabled).toBe(deviceSharingEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const deviceSharingEnabled of ['false', 0, null]) {
await expect(store.update({ deviceSharingEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing frosted glass to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
delete legacy.transparentFrostedEffectEnabled
await writeFile(filePath, JSON.stringify(legacy))
expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false })
expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, transparentFrostedEffectEnabled: undefined }).transparentFrostedEffectEnabled).toBe(true)
})
it('persists and publishes both frosted glass choices across unrelated updates and reloads', async () => {
const { filePath, store } = await createStore()
expect((await store.get()).transparentFrostedEffectEnabled).toBe(true)
const changed = vi.fn()
store.onChanged(changed)
for (const transparentFrostedEffectEnabled of [true, false]) {
const saved = await store.update({ transparentFrostedEffectEnabled })
expect(saved.transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(changed).toHaveBeenLastCalledWith(saved)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
expect(JSON.parse(await readFile(filePath, 'utf8')).transparentFrostedEffectEnabled).toBe(transparentFrostedEffectEnabled)
}
expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false })
for (const transparentFrostedEffectEnabled of ['false', 0, null]) {
await expect(store.update({ transparentFrostedEffectEnabled })).rejects.toThrow()
}
})
it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => {
const { filePath, store } = await createStore()
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false }
@@ -192,7 +251,7 @@ describe('ApplicationSettingsStore', () => {
const settings = await store.get()
expect(settings.warnings).toBeUndefined()
expect(settings.magicNotesEnabled).toBe(false)
expect(settings.applicationNavigation).toEqual({ order: expected, pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
await store.update({ checkUpdatesOnStartup: false })
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
@@ -202,7 +261,7 @@ describe('ApplicationSettingsStore', () => {
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
const { store, filePath } = await createStore()
const applicationNavigation = {
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false },
}
const changed = vi.fn()
@@ -247,7 +306,7 @@ describe('ApplicationSettingsStore', () => {
pinned: { 'magic-notes': false, 'local-inference': pinned }
}
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] }
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] }
expect((await store.get()).applicationNavigation).toEqual(normalized)
await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
+8
View File
@@ -251,6 +251,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR
export const defaultApplicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: true,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
@@ -258,6 +259,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
remoteProjectsEnabled: false,
applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true,
deviceSharingEnabled: false,
magicNotesEnabled: true,
heartbeatEnabled: false,
heartbeatReportTimeoutSeconds: defaultSupervisionTimeoutSeconds,
@@ -291,10 +293,12 @@ export class ApplicationSettingsStore {
StoredApplicationSettings,
| 'conversationHtmlRenderingEnabled'
| 'desktopNotificationsEnabled'
| 'transparentFrostedEffectEnabled'
| 'localToolEnvironment'
| 'version'
| 'applicationNavigation'
| 'localInferenceEnabled'
| 'deviceSharingEnabled'
| 'heartbeatEnabled'
| 'magicNoteCanvasPageCount'
> & {
@@ -526,6 +530,7 @@ export class ApplicationSettingsStore {
return {
checkUpdatesOnStartup: stored.checkUpdatesOnStartup,
desktopNotificationsEnabled: stored.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: stored.transparentFrostedEffectEnabled,
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
@@ -534,6 +539,7 @@ export class ApplicationSettingsStore {
remoteProjectsEnabled: stored.remoteProjectsEnabled,
applicationNavigation: stored.applicationNavigation,
localInferenceEnabled: stored.localInferenceEnabled,
deviceSharingEnabled: stored.deviceSharingEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
heartbeatEnabled: stored.heartbeatEnabled ?? false,
heartbeatReportTimeoutSeconds: stored.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
@@ -572,6 +578,7 @@ export class ApplicationSettingsStore {
const settings: ApplicationSettings = {
checkUpdatesOnStartup: next.checkUpdatesOnStartup,
desktopNotificationsEnabled: next.desktopNotificationsEnabled,
transparentFrostedEffectEnabled: next.transparentFrostedEffectEnabled,
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
@@ -580,6 +587,7 @@ export class ApplicationSettingsStore {
remoteProjectsEnabled: next.remoteProjectsEnabled,
applicationNavigation: next.applicationNavigation,
localInferenceEnabled: next.localInferenceEnabled,
deviceSharingEnabled: next.deviceSharingEnabled,
magicNotesEnabled: next.magicNotesEnabled,
heartbeatEnabled: next.heartbeatEnabled ?? false,
heartbeatReportTimeoutSeconds: next.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds,
@@ -3054,7 +3054,7 @@ describe('AssistantDatabase', () => {
])
expect(terminal[0]?.message).not.toHaveProperty('attachments')
migrated.close()
})
}, 15000)
it('rolls back both heartbeat failure updates atomically', async () => {
const directory = await mkdtemp(
@@ -3709,6 +3709,8 @@ describe('AssistantDatabase', () => {
ALTER TABLE tasks DROP COLUMN current_user_message_id;
ALTER TABLE tasks DROP COLUMN remote_recoverable;
DROP INDEX task_events_remote_provenance_unique;
DROP INDEX task_events_remote_task_idx;
DROP INDEX task_events_lifecycle_idx;
ALTER TABLE task_events DROP COLUMN remote_event_index;
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
ALTER TABLE task_events DROP COLUMN remote_operation_id;
+15 -1
View File
@@ -133,7 +133,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 51
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 52
export type RemoteTaskEventInput = {
taskId: string
@@ -11895,6 +11895,20 @@ export class AssistantDatabase {
database.exec('PRAGMA user_version = 51; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 52) {
database.exec('BEGIN IMMEDIATE')
try {
// Execution stats poll every few seconds. Filtering lifecycle and remote
// rows through task_events_task_idx reads every large stream payload row.
database.exec(`
CREATE INDEX IF NOT EXISTS task_events_lifecycle_idx
ON task_events(task_id, id) WHERE kind IN ('status', 'done', 'error');
CREATE INDEX IF NOT EXISTS task_events_remote_task_idx
ON task_events(task_id) WHERE remote_operation_id IS NOT NULL;
`)
database.exec('PRAGMA user_version = 52; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionReviewStore(): SupervisionReviewStore {
@@ -427,9 +427,12 @@ describe('execution duration evidence', () => {
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']) {
for (const alias of ['proof', 'tail', 'previous']) {
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
}
// Filtering these through task_events_task_idx would read every payload row.
expect(plan.some(detail => detail.includes('SEARCH remote USING INDEX task_events_remote_task_idx'))).toBe(true)
expect(plan.some(detail => detail.includes('SEARCH e USING INDEX task_events_lifecycle_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 })
@@ -261,29 +261,29 @@ describe('CapabilityService', () => {
).rejects.toThrow('Injected profile load failure')
await expect(
retrying.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
).resolves.toEqual({ enabled: true, supported: true })
expect(profileLoads).toBe(2)
})
it('reports safe enabled and supported capability status', async () => {
it('enables the browser by default and synchronizes its switch', async () => {
const { service } = await createService()
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: false, supported: true })
).resolves.toEqual({ enabled: true, supported: true })
await service.setComputerCapabilityEnabled(
'host-browser-control',
true
false
)
await expect(
service.getComputerCapabilityStatus('host-browser-control')
).resolves.toEqual({ enabled: true, supported: true })
).resolves.toEqual({ enabled: false, supported: true })
await expect(service.getSnapshot()).resolves.toMatchObject({
builtinMcpServers: expect.arrayContaining([
expect.objectContaining({
id: 'builtin-browser',
enabled: true
enabled: false
})
])
})
@@ -341,7 +341,7 @@ describe('CapabilityService', () => {
},
{
id: 'builtin-browser',
enabled: false,
enabled: true,
assignments: ['model', 'opencode', 'continue']
},
{
@@ -385,6 +385,7 @@ describe('CapabilityService', () => {
await service.setBuiltinMcpServerAssignments('story-graph', ['deepseek-harness'])
await expect(service.getEnabledBuiltinMcpServerIds('model')).resolves.not.toContain('story-graph')
await service.setBuiltinMcpServerEnabled('builtin-browser', false)
const reloaded = new CapabilityService(
filePath,
@@ -405,7 +406,7 @@ describe('CapabilityService', () => {
}),
expect.objectContaining({
id: 'builtin-browser',
enabled: true,
enabled: false,
assignments: ['continue']
})
])
@@ -1014,7 +1015,7 @@ describe('CapabilityService', () => {
computerCapabilities: [
expect.objectContaining({
id: 'host-browser-control',
enabled: false
enabled: true
}),
expect.objectContaining({
id: 'linux-desktop-control',
@@ -1158,7 +1159,7 @@ describe('CapabilityService', () => {
expect(await readFile(filePath, 'utf8')).toContain('"version": 6')
})
it('migrates the legacy browser switch into the built-in browser assignment', async () => {
it.each([true, false])('preserves the legacy browser switch (%s) during migration', async (enabled) => {
const { filePath, builtinRoot, importedRoot } = await createService()
await writeFile(
filePath,
@@ -1183,7 +1184,7 @@ describe('CapabilityService', () => {
webSearch: { enabled: true },
computerCapabilities: {
'host-browser-control': {
enabled: true,
enabled,
browserProfileId: null
},
'linux-desktop-control': {
@@ -1205,7 +1206,7 @@ describe('CapabilityService', () => {
builtinMcpServers: expect.arrayContaining([
{
id: 'builtin-browser',
enabled: true,
enabled,
assignments: ['model', 'opencode', 'continue']
}
])
@@ -1520,6 +1521,7 @@ describe('CapabilityService', () => {
),
diagnostics
})
await outsideElectron.service.setComputerCapabilityEnabled('host-browser-control', false)
await expect(
outsideElectron.service.setComputerCapabilityEnabled(
'host-browser-control',
+2 -2
View File
@@ -299,7 +299,7 @@ export type CapabilityServiceOptions = Readonly<{
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
return {
'host-browser-control': {
enabled: false,
enabled: true,
browserProfileId: null
},
'linux-desktop-control': {
@@ -310,7 +310,7 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili
}
function defaultBuiltinMcpServerStates(
browserEnabled = false
browserEnabled = true
): StoredCapabilities['builtinMcpServers'] {
const defaultState = (): z.infer<typeof builtinMcpServerStateSchema> => ({
enabled: true,
+46
View File
@@ -34,6 +34,52 @@ afterEach(async () => {
})
describe('DesktopDiagnostics', () => {
it('gates MCP metadata and discards unknown or unsafe fields', () => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed' }
const mcp = { phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: `sha256:${'a'.repeat(64)}`, token: 'private-token', headers: { authorization: 'private-token' } }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp })?.mcp).toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25, status: 406, correlationId: mcp.correlationId })
expect(normalizeDesktopDiagnosticRecord({ ...record, code: 'runtime.run.failed', mcp })).not.toHaveProperty('mcp')
expect(normalizeDesktopDiagnosticRecord({ ...record, component: 'desktop', mcp })).not.toHaveProperty('mcp')
for (const invalid of [{ phase: 'private-url' }, { category: 'private-body' }, { elapsedMs: -1 }, { elapsedMs: Infinity }, { elapsedMs: 1.5 }]) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, ...invalid } })).not.toHaveProperty('mcp')
}
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, status: 999, correlationId: 'raw-token' } })?.mcp)
.toEqual({ phase: 'initialize', category: 'http-rejection', elapsedMs: 25 })
})
it('renormalizes stored MCP metadata during export after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
const mcp = { phase: 'tool-discovery' as const, category: 'handler-failure' as const, elapsedMs: 42, correlationId: `sha256:${'b'.repeat(64)}` }
await diagnostics.recordFailure({ component: 'runtime', stage: 'connect', code: 'runtime.mcp.failed', error: new Error('private-error'), mcp })
await diagnostics.dispose()
const path = join(directory, 'desktop-diagnostics.ndjson')
const stored = JSON.parse(await readFile(path, 'utf8'))
expect(stored.mcp).toEqual(mcp)
await writeFile(path, JSON.stringify({ ...stored, message: 'private-body', mcp: { ...mcp, token: 'private-token', url: 'private-url', stack: 'private-stack', status: 'private-status' } }) + '\n')
const restarted = new DesktopDiagnostics(directory)
try {
const exported = (await restarted.exportRecent()).toString('utf8')
expect(JSON.parse(exported).mcp).toEqual(mcp)
expect(exported).not.toContain('private-')
} finally { await restarted.dispose() }
})
it.each(['connect', 'disconnect'])('allowlists runtime MCP %s attempts', (phase) => {
const record = { timestamp: '2026-09-30T00:00:00Z', component: 'runtime', stage: phase, code: 'runtime.mcp.failed' }
const mcp = { phase, category: 'transport-failure', elapsedMs: 1000, attempt: 2, kind: 'custom', correlationId: `sha256:${'c'.repeat(64)}` }
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, error: 'private-error', token: 'private-token' } })?.mcp).toEqual(mcp)
for (const category of ['initialization-failure', 'cleanup-failure', 'cancelled']) {
expect(normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, category } })?.mcp?.category).toBe(category)
}
for (const attempt of [0, 3, 1.5, '1']) {
const normalized = normalizeDesktopDiagnosticRecord({ ...record, mcp: { ...mcp, attempt, kind: 'private-kind' } })?.mcp
expect(normalized).not.toHaveProperty('attempt')
expect(normalized).not.toHaveProperty('kind')
expect(normalized?.correlationId).toBe(mcp.correlationId)
}
})
it('preserves renderer exit details on disk and in exports after restart', async () => {
const directory = await temporaryDirectory()
const diagnostics = new DesktopDiagnostics(directory)
+37 -1
View File
@@ -35,6 +35,7 @@ const allowedCodes = new Set([
'desktop.startup.failed',
'runtime.operation.failed',
'runtime.run.failed',
'runtime.mcp.failed',
'remote.connection.network',
'remote.connection.host-invalidated',
'remote.connection.host-identity',
@@ -68,6 +69,35 @@ export type DesktopDiagnosticComponent =
| 'runtime'
| 'remote-agent'
export type DesktopMcpDiagnosticMetadata = Readonly<{
phase: 'request' | 'authentication' | 'session' | 'initialize' | 'tool-discovery' | 'connect' | 'disconnect'
category: 'http-rejection' | 'handler-failure' | 'transport-failure' | 'initialization-failure' | 'cleanup-failure' | 'cancelled'
elapsedMs: number
status?: number
correlationId?: string
attempt?: 1 | 2
kind?: 'builtin' | 'custom'
}>
function normalizeMcpMetadata(value: unknown): DesktopMcpDiagnosticMetadata | undefined {
if (!value || typeof value !== 'object') return undefined
const candidate = value as DesktopMcpDiagnosticMetadata
if (!['request', 'authentication', 'session', 'initialize', 'tool-discovery', 'connect', 'disconnect'].includes(candidate.phase) ||
!['http-rejection', 'handler-failure', 'transport-failure', 'initialization-failure', 'cleanup-failure', 'cancelled'].includes(candidate.category) ||
!Number.isSafeInteger(candidate.elapsedMs) || candidate.elapsedMs < 0) return undefined
return Object.freeze({
phase: candidate.phase,
category: candidate.category,
elapsedMs: candidate.elapsedMs,
...(candidate.attempt === 1 || candidate.attempt === 2 ? { attempt: candidate.attempt } : {}),
...(candidate.kind === 'builtin' || candidate.kind === 'custom' ? { kind: candidate.kind } : {}),
...(Number.isInteger(candidate.status) && candidate.status! >= 100 && candidate.status! <= 599
? { status: candidate.status } : {}),
...(typeof candidate.correlationId === 'string' && /^sha256:[a-f0-9]{64}$/.test(candidate.correlationId)
? { correlationId: candidate.correlationId } : {})
})
}
export type DesktopDiagnosticFailure = Readonly<{
component: DesktopDiagnosticComponent
stage: string
@@ -75,6 +105,7 @@ export type DesktopDiagnosticFailure = Readonly<{
error: unknown
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticFailureObserver = (
@@ -90,6 +121,7 @@ export type DesktopDiagnosticRecord = Readonly<{
message: string
reason?: string
exitCode?: number
mcp?: DesktopMcpDiagnosticMetadata
}>
export type DesktopDiagnosticsOptions = Readonly<{
@@ -178,6 +210,8 @@ export function normalizeDesktopDiagnosticRecord(
const component = safeComponent(candidate.component)
const stage = safeStage(candidate.stage)
const code = safeCode(candidate.code)
const mcp = component === 'runtime' && code === 'runtime.mcp.failed'
? normalizeMcpMetadata(candidate.mcp) : undefined
const rendererGone = component === 'desktop' && stage === 'renderer' &&
code === 'desktop.renderer.gone'
return Object.freeze({
@@ -187,6 +221,7 @@ export function normalizeDesktopDiagnosticRecord(
code,
errorType: safeStoredErrorType(candidate.errorType),
message: fixedMessage(component, stage),
...(mcp ? { mcp } : {}),
...(rendererGone && typeof candidate.reason === 'string' &&
rendererExitReasons.has(candidate.reason) ? { reason: candidate.reason } : {}),
...(rendererGone && Number.isSafeInteger(candidate.exitCode)
@@ -266,7 +301,8 @@ export class DesktopDiagnostics {
code: failure.code,
errorType: safeErrorType(failure.error),
reason: failure.reason,
exitCode: failure.exitCode
exitCode: failure.exitCode,
mcp: failure.mcp
})
if (normalized === undefined) {
return Promise.reject(
+25
View File
@@ -0,0 +1,25 @@
import { expect, it, vi } from 'vitest'
import { registerDeviceSharingIpc } from './device-sharing-ipc'
import { ipcChannels } from '../shared/ipc-channels'
const handlers = vi.hoisted(() => new Map<string, (event: unknown, input?: unknown) => unknown>())
vi.mock('electron', () => ({ ipcMain: {
handle: (channel: string, handler: (event: unknown, input?: unknown) => unknown) => handlers.set(channel, handler),
removeHandler: (channel: string) => handlers.delete(channel)
} }))
it('checks the trusted main frame on every explicit method and validates mutation input', () => {
const webContents = { mainFrame: { url: 'file:///app.html' }, getURL: () => 'file:///app.html' }
const service = { getSettings: vi.fn(), saveSettings: vi.fn(), registerDevice: vi.fn(), getCatalog: vi.fn(), publish: vi.fn(), revoke: vi.fn() }
const dispose = registerDeviceSharingIpc({ webContents } as never, service as never)
const event = { sender: webContents, senderFrame: webContents.mainFrame }
expect(handlers.size).toBe(6)
for (const handler of handlers.values()) {
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame })).toThrow('IPC')
expect(() => handler({ sender: webContents, senderFrame: { url: 'file:///app.html' } })).toThrow('IPC')
}
expect(() => handlers.get(ipcChannels.sharingSettingsSave)!(event, { deviceId: 'forged', name: 'x', serverUrl: 'http://localhost' })).toThrow()
expect(() => handlers.get(ipcChannels.sharingPublish)!(event, { name: 'x' })).toThrow()
expect(() => handlers.get(ipcChannels.sharingRevoke)!(event, '')).toThrow()
handlers.get(ipcChannels.sharingRegister)!(event)
expect(service.registerDevice).toHaveBeenCalledOnce()
dispose(); expect(handlers.size).toBe(0)
})
+24
View File
@@ -0,0 +1,24 @@
import { ipcMain, type BrowserWindow } from 'electron'
import { ipcChannels } from '../shared/ipc-channels'
import { sharingIdSchema, sharingPublicationDraftSchema, sharingSettingsInputSchema } from '../shared/device-sharing-contracts'
import { assertTrustedSender } from './trusted-ipc-sender'
import type { DeviceSharingService } from './device-sharing-service'
export function registerDeviceSharingIpc(window: BrowserWindow, service: DeviceSharingService): () => void {
const handlers = {
[ipcChannels.sharingSettingsGet]: () => service.getSettings(),
[ipcChannels.sharingSettingsSave]: (input: unknown) => service.saveSettings(sharingSettingsInputSchema.parse(input)),
[ipcChannels.sharingRegister]: () => service.registerDevice(),
[ipcChannels.sharingCatalog]: () => service.getCatalog(),
[ipcChannels.sharingPublish]: (input: unknown) => service.publish(sharingPublicationDraftSchema.parse(input)),
[ipcChannels.sharingRevoke]: (input: unknown) => service.revoke(sharingIdSchema.parse(input))
}
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.removeHandler(channel)
ipcMain.handle(channel, (event, input: unknown) => {
assertTrustedSender(event, window)
return handler(input)
})
}
return () => { for (const channel of Object.keys(handlers)) ipcMain.removeHandler(channel) }
}
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { createServer } from 'node:http'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { DeviceSharingService } from './device-sharing-service'
import type { SharingDevice, SharingPublication } from '../shared/device-sharing-contracts'
const cleanup: (() => Promise<unknown>)[] = []
afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn() })
async function setup(timeout = 1000) {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-sharing-'))
cleanup.push(() => rm(directory, { recursive: true, force: true }))
const devices: SharingDevice[] = []
const publications: SharingPublication[] = []
const requests: { path: string; method: string; body: Record<string, unknown> }[] = []
let failure = 0
let stall = false
const server = createServer(async (request, response) => {
if (stall) return
if (failure) { response.writeHead(failure).end(); return }
const buffers: Buffer[] = []
for await (const chunk of request) buffers.push(Buffer.from(chunk))
const body = JSON.parse(Buffer.concat(buffers).toString() || '{}')
const path = request.url!
requests.push({ path, method: request.method!, body })
let value: unknown
if (path.endsWith('/devices/register')) {
value = { ...body, registeredAt: '2026-10-01', updatedAt: '2026-10-01' }
devices.splice(0, devices.length, value as SharingDevice)
} else if (path.endsWith('/devices')) value = { devices }
else if (request.method === 'GET') value = { publications }
else if (path.endsWith('/revoke')) {
const entry = publications.find(item => path.includes(item.id))!
entry.status = 'revoked'; value = entry
} else {
value = { ...body, status: 'published', createdAt: '2026-10-01', updatedAt: '2026-10-01' }
publications.push(value as SharingPublication)
}
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(value))
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
cleanup.push(() => new Promise<void>(resolve => { server.closeAllConnections(); server.close(() => resolve()) }))
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}`
const file = join(directory, 'settings.json')
const service = new DeviceSharingService(file, 'test-version', timeout)
await service.saveSettings({ name: 'Desktop', serverUrl: url })
return { service, file, url, requests, publications, fail: (code: number) => { failure = code }, stall: () => { stall = true } }
}
it('persists one identity across concurrent first reads, saves and restarts without accepting renderer identity', async () => {
const setupResult = await setup()
const file = `${setupResult.file}.first-read`
const url = setupResult.url
const service = new DeviceSharingService(file, 'test-version')
const [first, second] = await Promise.all([service.getSettings(), service.getSettings()])
expect(first.deviceId).toBe(second.deviceId)
await service.saveSettings({ name: 'Renamed', serverUrl: `${url}/` })
expect(await new DeviceSharingService(file, 'v2').getSettings()).toEqual({ ...first, name: 'Renamed', serverUrl: url })
await expect(service.saveSettings({ name: 'Bad', serverUrl: url, deviceId: 'forged' })).rejects.toThrow()
expect(JSON.parse(await readFile(file, 'utf8')).deviceId).toBe(first.deviceId)
await writeFile(file, '{broken')
await expect(new DeviceSharingService(file, 'v2').getSettings()).rejects.toThrow()
expect(await readFile(file, 'utf8')).toBe('{broken')
})
it('uses the exact HTTP contract for registration, catalog, both publication kinds and own-device revocation', async () => {
const { service, requests, publications } = await setup()
const device = await service.registerDevice()
expect(requests[0]).toEqual({ path: '/api/v1/sharing/devices/register', method: 'POST', body: {
id: device.id, name: 'Desktop', platform: process.platform, appVersion: 'test-version'
} })
const draft = { name: 'Knowledge', description: 'Metadata only', kind: 'knowledge', sourceMode: 'server', permissions: { search: true, read: false, download: true } }
const publication = await service.publish(draft)
expect(publication).toMatchObject({ ...draft, deviceId: device.id, status: 'published' })
expect(await service.getCatalog()).toEqual({ devices: [device], publications: [publication] })
expect(await service.publish({ ...draft, kind: 'capability', sourceMode: 'device' })).toMatchObject({ permissions: { search: false, read: false, download: false } })
publications.push({ ...publication, id: 'other-publication', deviceId: 'other-device' })
await expect(service.revoke('other-publication')).rejects.toThrow('does not belong')
expect(requests.some(item => item.path.includes('other-publication/revoke'))).toBe(false)
expect(await service.revoke(publication.id)).toMatchObject({ status: 'revoked' })
expect(requests.at(-2)?.path).toBe(`/api/v1/sharing/publications?deviceId=${device.id}`)
expect(requests.at(-1)).toMatchObject({ path: `/api/v1/sharing/publications/${publication.id}/revoke`, method: 'POST', body: {} })
})
it('preserves HTTP failure status and bounds a stalled request', async () => {
const { service, fail, stall } = await setup(50)
fail(503)
await expect(service.getCatalog()).rejects.toThrow('HTTP 503')
fail(0); stall()
await expect(service.registerDevice()).rejects.toThrow(/timeout/i)
})
+100
View File
@@ -0,0 +1,100 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { hostname } from 'node:os'
import { z } from 'zod'
import {
sharingDeviceSchema, sharingIdSchema, sharingPublicationDraftSchema, sharingPublicationSchema,
sharingSettingsInputSchema, sharingSettingsSchema,
type SharingCatalog, type SharingSettings
} from '../shared/device-sharing-contracts'
import { assertSupportedSettingsVersion, isMissingFileError, writeJsonFileAtomically } from './settings-file-utils'
const storedSchema = sharingSettingsSchema.extend({ version: z.literal(1) }).strict()
export class DeviceSharingService {
private settings?: Promise<SharingSettings>
private updateQueue: Promise<unknown> = Promise.resolve()
constructor(private readonly filePath: string, private readonly appVersion: string, private readonly timeoutMs = 10_000) {}
getSettings(): Promise<SharingSettings> {
if (!this.settings) {
this.settings = (async () => {
try {
const value: unknown = JSON.parse(await readFile(this.filePath, 'utf8'))
assertSupportedSettingsVersion(value, 1, version => `Unsupported sharing settings version: ${version}`)
const stored = storedSchema.parse(value)
return { deviceId: stored.deviceId, name: stored.name, serverUrl: stored.serverUrl }
} catch (error) {
// Never silently replace a saved device identity on a read/parse failure.
if (!isMissingFileError(error)) throw error
const settings = { deviceId: randomUUID(), name: hostname(), serverUrl: 'http://127.0.0.1:8787' }
await writeJsonFileAtomically(this.filePath, { ...settings, version: 1 })
return settings
}
})().catch(error => { this.settings = undefined; throw error })
}
return this.settings
}
saveSettings(input: unknown): Promise<SharingSettings> {
const operation = this.updateQueue.then(async () => {
const patch = sharingSettingsInputSchema.parse(input)
const current = await this.getSettings()
const next = { ...current, ...patch, serverUrl: patch.serverUrl.replace(/\/+$/, '') }
await writeJsonFileAtomically(this.filePath, { ...next, version: 1 })
this.settings = Promise.resolve(next)
return next
})
this.updateQueue = operation.catch(() => undefined)
return operation
}
private async request<T>(settings: SharingSettings, path: string, schema: z.ZodType<T>, body?: unknown): Promise<T> {
const response = await fetch(`${settings.serverUrl.replace(/\/+$/, '')}/api/v1/sharing/${path}`, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(this.timeoutMs)
})
if (!response.ok) throw new Error(`Sharing HTTP ${response.status} ${response.statusText}`)
return schema.parse(await response.json())
}
async registerDevice() {
const settings = await this.getSettings()
return this.request(settings, 'devices/register', sharingDeviceSchema, {
id: settings.deviceId, name: settings.name, platform: process.platform, appVersion: this.appVersion
})
}
async getCatalog(): Promise<SharingCatalog> {
const settings = await this.getSettings()
const [devices, publications] = await Promise.all([
this.request(settings, 'devices', z.object({ devices: z.array(sharingDeviceSchema) })),
this.request(settings, 'publications', z.object({ publications: z.array(sharingPublicationSchema) }))
])
return { ...devices, ...publications }
}
async publish(input: unknown) {
const draft = sharingPublicationDraftSchema.parse(input)
const settings = await this.getSettings()
return this.request(settings, 'publications', sharingPublicationSchema, {
...draft, id: randomUUID(), deviceId: settings.deviceId,
permissions: draft.kind === 'knowledge' ? draft.permissions : { search: false, read: false, download: false }
})
}
async revoke(input: unknown) {
const id = sharingIdSchema.parse(input)
const settings = await this.getSettings()
const { publications } = await this.request(settings,
`publications?deviceId=${encodeURIComponent(settings.deviceId)}`,
z.object({ publications: z.array(sharingPublicationSchema) }))
if (!publications.some(item => item.id === id && item.deviceId === settings.deviceId)) {
throw new Error('Publication does not belong to this device ID')
}
return this.request(settings, `publications/${encodeURIComponent(id)}/revoke`, sharingPublicationSchema, {})
}
}
@@ -113,6 +113,23 @@ describe('FeedbackService', () => {
code: 'runtime.run.failed',
errorType: 'TimeoutError',
message: 'password=hunter2'
},
{
timestamp: '2026-08-25T01:02:04.000Z',
component: 'runtime' as const,
stage: 'connect',
code: 'runtime.mcp.failed',
errorType: 'Error',
message: 'private-mcp-message',
mcp: {
phase: 'tool-discovery' as const,
category: 'handler-failure' as const,
elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`,
token: 'private-mcp-token',
headers: { authorization: 'private-mcp-auth' },
url: 'private-mcp-url'
}
}
])
const fixture = service(
@@ -146,6 +163,11 @@ describe('FeedbackService', () => {
'{"timestamp":"2026-08-25T01:02:03.000Z","component":"runtime","stage":"run","code":"runtime.run.failed","errorType":"TimeoutError","message":"Runtime request failed"}'
)
expect(payload.description).not.toContain('hunter2')
expect(payload.description).toContain(JSON.stringify({
phase: 'tool-discovery', category: 'handler-failure', elapsedMs: 123,
correlationId: `sha256:${'a'.repeat(64)}`
}))
expect(payload.description).not.toContain('private-mcp-')
expect(payload.description).toMatch(
/\n\[GOODBUDDY_DESKTOP_DIAGNOSTICS_V1_END\]$/u
)
+9
View File
@@ -86,6 +86,8 @@ import {
} from './feedback/feedback-http-client'
import { FeedbackService } from './feedback/feedback-service'
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
import { registerDeviceSharingIpc } from './device-sharing-ipc'
import { DeviceSharingService } from './device-sharing-service'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
import {
@@ -211,6 +213,7 @@ let tray: Tray | undefined
let isQuitting = false
let removeIpcHandlers: (() => Promise<void>) | undefined
let removeFeedbackIpcHandler: (() => void) | undefined
let removeDeviceSharingIpc: (() => void) | undefined
let runtime: AgentRuntimeController | undefined
let selectedRuntimeManager: SelectedRuntimeManager | undefined
const localRuntimeRegistry = new LocalRuntimeRegistry()
@@ -1030,6 +1033,7 @@ if (hasSingleInstanceLock) {
const startupKnowledgeGateway = new KnowledgeMcpGateway(
startupKnowledgeService,
{
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
@@ -1078,6 +1082,7 @@ if (hasSingleInstanceLock) {
: Promise.resolve([])
])
return createAgentRuntime(defaultWorkspace, settings, {
observeFailure: observeDesktopFailure,
localRuntimeRegistry,
skillInstructions: skillContext.instructions,
skillPackages: skillContext.packages,
@@ -1495,6 +1500,7 @@ if (hasSingleInstanceLock) {
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
openExternal: (url) => shell.openExternal(url),
createGateway: () => new KnowledgeMcpGateway(startupKnowledgeService, {
observeFailure: observeDesktopFailure,
storyGraphService: {
available: async ({ runtimeTarget }) => (await applicationSettingsStore.get()).heartbeatEnabled === true &&
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
@@ -1573,6 +1579,8 @@ if (hasSingleInstanceLock) {
startupFeedbackService
)
loadMainWindow(mainWindow)
removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow,
new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion()))
setImmediate(() => {
void repairStaleWindowsNotificationShortcuts({
platform: process.platform,
@@ -1661,6 +1669,7 @@ app.on('before-quit', (event) => {
() => storageUpgrade,
() => feedbackService?.dispose(),
() => removeFeedbackIpcHandler?.(),
() => removeDeviceSharingIpc?.(),
() => dshExtensionInstaller?.dispose(),
() => imageGenerationService?.dispose(),
() => knowledgeService?.beginShutdown(),
+2
View File
@@ -9319,6 +9319,8 @@ describe('registerIpcHandlers agent terminal state', () => {
{ isAvailable: () => true, encrypt: (value) => Buffer.from(value), decrypt: (value) => value.toString() }
)
const settings = { vaultPath: join(root, 'Saved Vault') }
// This harness exercises Obsidian grants and does not install a browser service.
await capabilities.setBuiltinMcpServerEnabled('builtin-browser', false)
await capabilities.updateObsidianSettings(settings)
if (enabled) await capabilities.setBuiltinMcpServerEnabled('obsidian', true)
if (!assigned) await capabilities.setBuiltinMcpServerAssignments('obsidian', ['continue'])
+4
View File
@@ -3740,6 +3740,9 @@ export function registerIpcHandlers(
}, () => normalizeInteractiveWorkMode(assistantDatabase.getConversation(enrichedRequest.conversationId).workMode))
: undefined
const imageToolAvailable = Boolean(await imageToolBinding?.describe())
// Remote Agents write the file on the remote host; Desktop streams the bytes on demand.
const imageSaveAvailable = Boolean(imageToolBinding?.save && normalizedWorkMode === 'execute' &&
await imageToolBinding.describeSave?.())
const scopedCapability = await grantScopedDataCapability({
storyGraph: selectedRuntimeTarget && applicationSettings?.heartbeatEnabled
? { runtimeTarget: selectedRuntimeTarget, projectId: enrichedRequest.projectId } : undefined,
@@ -3773,6 +3776,7 @@ export function registerIpcHandlers(
const knowledgeCapabilityToken = scopedCapability.token
const availableTools = [
...(imageToolAvailable ? ['generate_image'] : []),
...(imageSaveAvailable ? ['save_image'] : []),
...(webSearchEnabled ? ['web_search', 'web_fetch'] : []),
...(!agentRuntimeSelected && !imageGeneration
? ['workspace_rg', 'workspace_read_text', 'output_read', 'subagent_delegate']
@@ -2,13 +2,15 @@ import type { ImageToolBinding } from '../agent/image-tool-binding'
import type { RuntimeProtocolBinaryChannel } from './protocol-remote-runtime-channel'
import {
decodeRemoteImageToolMessage, encodeRemoteImageToolMessage,
remoteImageToolCallSchema, remoteImageToolReplySchema
remoteImageSaveChunkBytes, remoteImageToolCallSchema, remoteImageToolReplySchema
} from '../../shared/remote-image-tool-contracts'
export class MainImageToolSession {
private readonly wait = new AbortController()
private readonly unsubscribe: () => void
private readonly removeAbort: () => void
/** Encoded bytes of the image currently being streamed to the Agent; one save at a time per prompt. */
private saveCache?: { key: string; bytes: Buffer }
constructor(
private readonly channel: RuntimeProtocolBinaryChannel,
@@ -28,6 +30,7 @@ export class MainImageToolSession {
if (this.wait.signal.aborted) return
// This aborts binding.call's wait, never the Main-owned image operation.
this.wait.abort()
this.saveCache = undefined
this.removeAbort()
this.unsubscribe()
this.channel.close()
@@ -46,7 +49,22 @@ export class MainImageToolSession {
let reply: ReturnType<typeof remoteImageToolReplySchema.parse>
try {
this.wait.signal.throwIfAborted()
if ('name' in call) {
if ('name' in call && call.name === 'save_image_read') {
if (!this.binding?.readForSave || this.binding.context.workMode !== 'execute') throw new Error('Saving images is unavailable in Ask mode')
const { artifactId, mimeType, offset } = call.input
const cacheKey = `${artifactId}:${mimeType}`
let bytes = this.saveCache?.key === cacheKey ? this.saveCache.bytes : undefined
if (!bytes || offset === 0) {
bytes = await this.binding.readForSave(artifactId, mimeType)
this.saveCache = { key: cacheKey, bytes }
}
if (offset >= bytes.byteLength) throw new Error('Image chunk offset is out of range')
const end = Math.min(offset + remoteImageSaveChunkBytes, bytes.byteLength)
if (end === bytes.byteLength) this.saveCache = undefined
reply = remoteImageToolReplySchema.parse({ callId: call.callId, imageChunk: {
mimeType, totalBytes: bytes.byteLength, offset, data: bytes.subarray(offset, end).toString('base64')
} })
} else if ('name' in call) {
if (!this.storyGraph) throw new Error('Story Graph capability unavailable')
const storyGraphResult = call.name === 'story_graph_list'
? { available: await this.storyGraph.available() }
@@ -597,13 +597,15 @@ export class ProtocolRemoteRuntimeChannel
this.#imageTool = undefined
const description = preparation.workMode === 'execute' ? await imageToolBinding?.describe() : undefined
const storyGraph = await storyGraphBinding?.available()
const saveDescription = preparation.workMode === 'execute' && imageToolBinding?.readForSave
? await imageToolBinding.describeSave?.() : undefined
waitSignal?.throwIfAborted()
let imageTool: z.infer<typeof remotePromptOperationPreparationSchema>['imageTool']
if ((description && imageToolBinding) || storyGraph) {
if ((description && imageToolBinding) || storyGraph || saveDescription) {
this.#assertCurrent()
const binary = this.#state.client.allocateBinaryChannel?.({ kind: 'blob' })
if (!binary) throw new Error('Remote image tool transport is unavailable')
imageTool = { channelId: binary.channelId, channelEpoch: binary.channelEpoch, ...(description ? { description } : {}), ...(storyGraph ? { storyGraph: true } : {}) }
imageTool = { channelId: binary.channelId, channelEpoch: binary.channelEpoch, ...(description ? { description } : {}), ...(storyGraph ? { storyGraph: true } : {}), ...(saveDescription ? { saveDescription } : {}) }
this.#imageTool = new MainImageToolSession(binary, imageToolBinding, waitSignal, storyGraphBinding)
}
try {
+8
View File
@@ -808,6 +808,14 @@ const desktopApi: DesktopApi = {
act: (input) => ipcRenderer.invoke(ipcChannels.localInferenceAct, input),
cancel: (taskId) => ipcRenderer.invoke(ipcChannels.localInferenceCancel, { taskId })
},
sharing: {
getSettings: () => ipcRenderer.invoke(ipcChannels.sharingSettingsGet),
saveSettings: input => ipcRenderer.invoke(ipcChannels.sharingSettingsSave, input),
registerDevice: () => ipcRenderer.invoke(ipcChannels.sharingRegister),
getCatalog: () => ipcRenderer.invoke(ipcChannels.sharingCatalog),
publish: input => ipcRenderer.invoke(ipcChannels.sharingPublish, input),
revoke: id => ipcRenderer.invoke(ipcChannels.sharingRevoke, id)
},
speechModels: {
getSnapshot: () =>
ipcRenderer.invoke(
+15 -6
View File
@@ -959,23 +959,32 @@ describe('ActivityPanel', () => {
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument()
})
it('shows an empty system tab and refreshes usage on demand', async () => {
it('shows an empty system tab and refreshes from the header on demand', async () => {
let resolveRefresh!: () => void
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
const onRefresh = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
records={[]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
const header = screen.getByRole('heading', { level: 1, name: '运行记录' }).closest('header')!
fireEvent.click(within(header).getByRole('button', { name: '刷新' }))
expect(onRefresh).toHaveBeenCalledOnce()
const busy = screen.getByRole('button', { name: '正在刷新' })
expect(busy).toBeDisabled()
fireEvent.click(busy)
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
expect(onRefresh).toHaveBeenCalledOnce()
resolveRefresh()
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
})
it('offers the header refresh on the task tab too', () => {
const onRefresh = vi.fn(async () => {})
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefresh={onRefresh}
records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
expect(onRefresh).toHaveBeenCalledOnce()
})
it('hides the refresh button when no refresh handler is provided', () => {
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
+27 -24
View File
@@ -45,8 +45,11 @@ export type ActivityPanelProps = {
tokenUsage: TokenUsageSummary
onClear: () => void
onOpenConversation: (conversationId: string) => void
/** Re-reads token usage from storage; the button is hidden when omitted. */
onRefreshTokenUsage?: () => Promise<void>
/**
* Reloads the activity snapshot and token usage. The page does not follow
* live updates; the header refresh button is hidden when omitted.
*/
onRefresh?: () => Promise<void>
}
type ConversationActivityGroup = {
@@ -251,7 +254,7 @@ export const ActivityPanel = memo(function ActivityPanel({
tokenUsage,
onClear,
onOpenConversation,
onRefreshTokenUsage
onRefresh
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
@@ -262,7 +265,7 @@ export const ActivityPanel = memo(function ActivityPanel({
useState<Exclude<TokenUsageGroup, 'source'>>('project')
const [systemTokenGroup, setSystemTokenGroup] =
useState<SystemTokenUsageGroup>('source')
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
() => new Set()
)
@@ -612,15 +615,15 @@ export const ActivityPanel = memo(function ActivityPanel({
: row.label
return { label }
}
const refreshTokenUsage = async (): Promise<void> => {
if (!onRefreshTokenUsage || refreshingTokenUsage) {
const refresh = async (): Promise<void> => {
if (!onRefresh || refreshing) {
return
}
setRefreshingTokenUsage(true)
setRefreshing(true)
try {
await onRefreshTokenUsage()
await onRefresh()
} finally {
setRefreshingTokenUsage(false)
setRefreshing(false)
}
}
const renderRecordCard = (
@@ -760,6 +763,21 @@ export const ActivityPanel = memo(function ActivityPanel({
className="activity-panel"
>
<PageHeader
actions={
onRefresh && (
<button
aria-busy={refreshing}
className="secondary-button activity-panel__refresh"
disabled={refreshing}
onClick={() => void refresh()}
title={t('refresh.hint')}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{refreshing ? t('refresh.refreshing') : t('refresh.label')}
</button>
)
}
help={t('header.description')}
headingId="activity-panel-title"
icon={<Activity size={20} />}
@@ -1125,21 +1143,6 @@ export const ActivityPanel = memo(function ActivityPanel({
options={groups}
value={group}
/>
{onRefreshTokenUsage && (
<button
aria-busy={refreshingTokenUsage}
className="secondary-button token-usage__refresh"
disabled={refreshingTokenUsage}
onClick={() => void refreshTokenUsage()}
title={t('tokenUsage.refreshHint')}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{refreshingTokenUsage
? t('tokenUsage.refreshing')
: t('tokenUsage.refresh')}
</button>
)}
</header>
{description && (
<p className="token-usage__description">{description}</p>
+469 -32
View File
@@ -55,6 +55,43 @@ const speechRecognitionMocks = vi.hoisted(() => ({
const messageRenderProbe = vi.hoisted(() => vi.fn());
const assistantTasksProbe = vi.hoisted(() => vi.fn());
const activityRenderProbe = vi.hoisted(() => vi.fn());
const historyRenderProbes = vi.hoisted(() => ({
pane: vi.fn(), task: vi.fn(), checklist: vi.fn(),
instrument<T>(component: T, probe: () => void): T {
const wrap = (render: (...args: never[]) => unknown) => new Proxy(render, {
apply(target, receiver, args) {
probe();
return Reflect.apply(target, receiver, args);
},
});
// Preserve the production memo boundary, and count calls inside it.
return (typeof component === 'function'
? wrap(component as (...args: never[]) => unknown)
: { ...component, type: wrap((component as { type: (...args: never[]) => unknown }).type) }) as T;
},
}));
vi.mock('./ChatHistoryPane', async (importOriginal) => {
const original = await importOriginal<typeof import('./ChatHistoryPane')>();
return { ...original, ChatHistoryPane: historyRenderProbes.instrument(original.ChatHistoryPane, historyRenderProbes.pane) };
});
vi.mock('./ConversationTaskStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
});
const sidebarRenderProbes = vi.hoisted(() => ({ row: vi.fn(), projectActivity: vi.fn() }));
vi.mock('./OverflowMarquee', async (importOriginal) => {
const original = await importOriginal<typeof import('./OverflowMarquee')>();
return { ...original, OverflowMarquee: historyRenderProbes.instrument(original.OverflowMarquee, sidebarRenderProbes.row) };
});
vi.mock('./ProjectActivity', async (importOriginal) => {
const original = await importOriginal<typeof import('./ProjectActivity')>();
return { ...original, ProjectActivity: historyRenderProbes.instrument(original.ProjectActivity, sidebarRenderProbes.projectActivity) };
});
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
});
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
@@ -217,6 +254,12 @@ const terminalSnapshot: TerminalSnapshot = {
};
const api: DesktopApi & RuntimeNativeClientApi = {
sharing: {
getSettings: vi.fn(async () => ({ deviceId: 'test-device', name: 'Test device', serverUrl: 'http://127.0.0.1:8787' })),
saveSettings: vi.fn(), registerDevice: vi.fn(),
getCatalog: vi.fn(async () => ({ devices: [], publications: [] })),
publish: vi.fn(), revoke: vi.fn()
},
openRuntimeNativeClient: vi.fn(async () => ({ kind: "terminal" as const, terminal: terminalSnapshot })),
getRuntimeNativeClient: vi.fn(async () => null),
stopRuntimeNativeClient: vi.fn(async () => undefined),
@@ -1183,10 +1226,11 @@ function installRemoteProjectsSetting(enabled: boolean): {
let settings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
@@ -2074,7 +2118,7 @@ describe("App", () => {
).toBeInTheDocument();
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
"placeholder",
"Message GoodBuddy…\nEnter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access",
"Enter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access",
);
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
"title",
@@ -3063,8 +3107,11 @@ describe("App", () => {
await screen.findByText(new RegExp(delta.trim(), "u"));
}
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
activityRenderProbe.mockClear();
act(() => agentListener?.({ requestId, type: "done" }));
await waitFor(() => expect(activityRenderProbe.mock.calls.length).toBeGreaterThan(0));
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
// The hidden page keeps its snapshot; it refreshes when shown again.
expect.soft(activityRenderProbe).toHaveBeenCalledTimes(0);
expect(route).toHaveAttribute("hidden");
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
expect(screen.getByRole("heading", { name: "运行记录" })).toBe(heading);
@@ -3263,10 +3310,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3367,10 +3415,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3383,10 +3432,11 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3485,10 +3535,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -3501,10 +3552,11 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -4498,7 +4550,142 @@ describe("App", () => {
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
});
it("keeps each conversation history window and reader position", async () => {
it('retries into the active draft after warm conversation switches', async () => {
const histories: ConversationSnapshot[] = ['First retry', 'Second retry'].map((title, index) => ({
id: crypto.randomUUID(), projectId, title, updatedAt: 2 - index,
messages: [
{ id: crypto.randomUUID(), role: 'user', content: `${title} prompt`, createdAt: 1, state: 'complete' },
{ id: crypto.randomUUID(), role: 'assistant', content: `${title} failed`, createdAt: 2, state: 'error' },
],
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(histories);
render(<App />);
await screen.findByText('First retry failed');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
await screen.findByText('Second retry failed');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('Second retry prompt');
expect(composer).toHaveFocus();
fireEvent.click(screen.getByText('First retry').closest('button')!);
expect(composer).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
expect(composer).toHaveValue('First retry prompt');
fireEvent.click(screen.getByText('Second retry').closest('button')!);
expect(composer).toHaveValue('Second retry prompt');
expect(run).not.toHaveBeenCalled();
});
it("re-renders only the streaming conversation row and keeps sidebar actions live", async () => {
const otherConversations = Array.from({ length: 30 }, (_, c) => ({
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000000`,
projectId, title: `Sidebar row ${c}`, updatedAt: 1_000 + c,
messages: Array.from({ length: 40 }, (_, index) => ({
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-${String(index).padStart(12, "0")}`,
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `history ${index}`, createdAt: 1_000 + index, state: "complete" as const,
})),
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(otherConversations);
render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
await screen.findByText("Sidebar row 29");
fireEvent.change(composer, { target: { value: "Row isolation" } });
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: "text", delta: "Streaming" }));
await screen.findByText("Streaming");
const rows = () => [...document.querySelectorAll<HTMLElement>(".conversation-entry")];
expect(rows().length).toBeGreaterThanOrEqual(30);
const rowNodes = rows().map((row) => row.querySelector(".conversation-item__title")!);
sidebarRenderProbes.row.mockClear();
sidebarRenderProbes.projectActivity.mockClear();
for (const delta of [" one", " two", " three"]) {
act(() => agentListener?.({ requestId, type: "text", delta }));
}
await screen.findByText("Streaming one two three");
// Only the streaming conversation's own row may re-render per delta.
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(3);
expect(sidebarRenderProbes.projectActivity).toHaveBeenCalledTimes(0);
expect(rows().map((row) => row.querySelector(".conversation-item__title"))).toEqual(rowNodes);
sidebarRenderProbes.row.mockClear();
for (const value of ["a", "ab", "abc"]) {
fireEvent.change(composer, { target: { value } });
}
expect(sidebarRenderProbes.row).toHaveBeenCalledTimes(0);
// Row actions still run the latest App state after the rows were skipped.
const target = screen.getByText("Sidebar row 3").closest(".conversation-entry")!;
fireEvent.click(within(target as HTMLElement).getByRole("button", { name: "更多会话操作 Sidebar row 3" }));
fireEvent.click(await screen.findByRole("menuitem", { name: /^重命名/u }));
const renameInput = within(target as HTMLElement).getByRole("textbox");
fireEvent.change(renameInput, { target: { value: "Renamed while streaming" } });
fireEvent.submit(renameInput.closest("form")!);
const renamedTitle = () => within(target as HTMLElement).getByText("Renamed while streaming");
await waitFor(() => expect(renamedTitle()).toBeInTheDocument());
fireEvent.click(renamedTitle());
await waitFor(() => expect(renamedTitle().closest(".conversation-item")).toHaveClass("conversation-item--active"));
expect(document.querySelector(".conversation-title__text")).toHaveTextContent("Renamed while streaming");
expect(composer).toHaveValue("");
});
it("isolates history and task strips while typing during a running request", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
id: conversationId, projectId, title: 'Running render isolation', updatedAt: 2, messages: [],
}]);
vi.mocked(api.tasks.list).mockResolvedValue([{
id: crypto.randomUUID(), conversationId, projectId, title: 'Running scheduled task',
instructions: 'Keep task details live', origin: 'schedule', status: 'running',
createdAt: '2026-09-30T00:00:00.000Z',
}]);
render(<App />);
const taskStrip = await screen.findByRole('region', { name: '当前会话的任务' });
fireEvent.click(within(taskStrip).getByRole('button'));
expect(within(taskStrip).getByText('运行中')).toBeVisible();
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
fireEvent.change(composer, { target: { value: 'Running render isolation' } });
await waitFor(() => expect(screen.getByLabelText('加入待发送队列')).toBeEnabled());
fireEvent.click(screen.getByLabelText('加入待发送队列'));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
act(() => agentListener?.({ requestId, type: 'text', delta: 'Live response' }));
await screen.findByText('Live response');
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Working while typing', status: 'in_progress' }],
} }));
await screen.findByText('Working while typing');
expect(taskStrip).toBeVisible();
historyRenderProbes.pane.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
messageRenderProbe.mockClear();
const storageReads = vi.spyOn(Storage.prototype, 'getItem');
for (const value of ['a', 'ab', 'abc']) {
fireEvent.change(composer, { target: { value } });
}
expect.soft(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect.soft(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect.soft(messageRenderProbe).toHaveBeenCalledTimes(0);
expect.soft(storageReads.mock.calls.filter(([key]) => key === 'goodbuddy.conversations.v1')).toHaveLength(0);
storageReads.mockRestore();
act(() => agentListener?.({ requestId, type: 'text', delta: ' continues' }));
await screen.findByText('Live response continues');
expect(historyRenderProbes.pane).toHaveBeenCalled();
expect(historyRenderProbes.checklist).toHaveBeenCalled();
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
source: 'opencode', items: [{ content: 'Checklist updated live', status: 'in_progress' }],
} }));
await screen.findByText('Checklist updated live');
expect(composer).toHaveValue('abc');
fireEvent.click(screen.getByLabelText('停止生成'));
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
});
it("keeps each conversation history window and reader position on warm switches without rerendering messages", async () => {
const firstConversationId = "00000000-0000-4000-8000-000000000461";
const secondConversationId = "00000000-0000-4000-8000-000000000462";
const draftAttachment = {
@@ -4602,7 +4789,22 @@ describe("App", () => {
"第一段会话草稿",
);
expect(screen.getByText(draftAttachment.name)).toBeInTheDocument();
});
messageRenderProbe.mockClear();
historyRenderProbes.task.mockClear();
historyRenderProbes.checklist.mockClear();
fireEvent.click(screen.getByText("第二段会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第二段会话草稿"));
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第一段会话草稿"));
expect(messageRenderProbe).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
expect(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
expect(firstChat.scrollTop).toBe(225);
expect(reasoningDetails).toHaveAttribute('open');
historyRenderProbes.pane.mockClear();
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Warm cached draft' } });
expect(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
}, 15_000);
it("requires an accessible confirmation before permanently deleting a conversation", async () => {
render(<App />);
@@ -5783,7 +5985,7 @@ describe("App", () => {
render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
await waitFor(() => expect(composer).toHaveAttribute("placeholder",
`给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · ${modifier}+V 粘贴\n${modifier}+N 新建对话${shortcut ? ` · ${shortcut} 快捷唤起` : ""}`));
`Enter 发送 · Shift+Enter 换行 · ${modifier}+V 粘贴\n${modifier}+N 新建对话${shortcut ? ` · ${shortcut} 快捷唤起` : ""}`));
expect(document.querySelector(".composer-meta__shortcut")).toBeNull();
});
@@ -5798,10 +6000,11 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -5858,7 +6061,7 @@ describe("App", () => {
render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
await waitFor(() => expect(composer).toHaveAttribute("placeholder",
`给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话${infoState === "loaded" ? " · Ctrl+Shift+Space 快捷唤起" : ""}`));
`Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话${infoState === "loaded" ? " · Ctrl+Shift+Space 快捷唤起" : ""}`));
expect(document.querySelector(".composer-meta__shortcut")).toBeNull();
fireEvent.change(composer, { target: { value: "草稿" } });
expect(composer).toHaveValue("草稿");
@@ -5899,11 +6102,11 @@ describe("App", () => {
await screen.findByLabelText("向 GoodBuddy 提问");
expect(screen.queryByText("Ctrl+Alt+K")).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
.toHaveAttribute("placeholder", "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话"));
.toHaveAttribute("placeholder", "Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话"));
if (infoState === "pending") {
await act(async () => pendingInfo.resolve(initialInfo));
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute("placeholder",
"给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话");
"Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话");
}
} finally {
delete api.shortcuts;
@@ -6829,6 +7032,12 @@ describe("App", () => {
await within(composer).findByText("需求说明.md"),
).toBeInTheDocument();
expect(within(composer).getByText("页面截图.png")).toBeInTheDocument();
const draftImage = within(composer).getByRole("button", { name: "查看图片 页面截图.png" });
fireEvent.click(draftImage);
const draftViewer = await screen.findByRole("dialog", { name: "页面截图.png" });
expect(within(draftViewer).getByRole("img")).toHaveAttribute("src", imageAttachment.contentUrl);
fireEvent.click(within(draftViewer).getByRole("button", { name: "关闭图片查看器" }));
await waitFor(() => expect(draftImage).toHaveFocus());
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "分析这些附件" },
});
@@ -7705,7 +7914,78 @@ describe("App", () => {
expect(localStorage.getItem("goodbuddy.appearance-theme")).toBe("dark");
});
it("loads token usage in activity and refreshes it when a run finishes", async () => {
it('applies frosted glass only after saving and removes it when disabled', async () => {
const updates = api.updates!
const { container, unmount } = render(<App />)
const shell = container.querySelector('.app-shell')
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
expect(toggle.closest('.toggle-row')?.firstElementChild).toHaveTextContent('透明磨砂特效')
expect(toggle.closest('.toggle-row')?.lastElementChild).toBe(toggle)
await waitFor(() => expect(toggle).toBeEnabled())
const saved = { ...await updates.getSettings(), transparentFrostedEffectEnabled: true }
let resolveSave!: (settings: ApplicationSettings) => void
vi.mocked(updates.updateSettings).mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
fireEvent.click(toggle)
expect(toggle).toBeDisabled()
expect(toggle).not.toBeChecked()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: true })
await act(async () => resolveSave(saved))
expect(toggle).toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(toggle).toBeEnabled()
expect(shell).not.toHaveAttribute('data-frosted-glass')
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
})
expect(updates.updateSettings).toHaveBeenLastCalledWith({ transparentFrostedEffectEnabled: false })
fireEvent.click(toggle)
await waitFor(() => expect(shell).toHaveAttribute('data-frosted-glass', 'true'))
unmount()
expect(document.documentElement).not.toHaveAttribute('data-frosted-glass')
const reloaded = render(<App />)
await waitFor(() => expect(reloaded.container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true'))
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
})
it('retains confirmed frosted glass after a failed save and allows rereading locked settings', async () => {
const updates = api.updates!
await updates.updateSettings({ transparentFrostedEffectEnabled: true })
const { container } = render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '设置' }))
fireEvent.click(await screen.findByRole('tab', { name: '外观' }))
const toggle = screen.getByRole('switch', { name: '透明磨砂特效' })
await waitFor(() => {
expect(toggle).toBeEnabled()
expect(toggle).toBeChecked()
})
vi.mocked(updates.updateSettings).mockRejectedValueOnce(new Error('response lost'))
vi.mocked(updates.getSettings).mockRejectedValueOnce(new Error('read unavailable'))
fireEvent.click(toggle)
await screen.findByText('无法确认保存结果,请重新读取后再修改。')
expect(toggle).toBeChecked()
expect(toggle).toBeDisabled()
expect(container.querySelector('.app-shell')).toHaveAttribute('data-frosted-glass', 'true')
expect(document.documentElement).toHaveAttribute('data-frosted-glass', 'true')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(toggle).toBeEnabled())
fireEvent.click(toggle)
await waitFor(() => {
expect(toggle).not.toBeChecked()
expect(container.querySelector('.app-shell')).not.toHaveAttribute('data-frosted-glass')
})
})
it("loads token usage in activity and refreshes it on demand after a run", async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 1,
@@ -7795,11 +8075,68 @@ describe("App", () => {
type: "done",
});
});
await waitFor(() =>
expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(),
);
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce();
expect(within(stats).getByText("120")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
);
expect(within(stats).getByText("345")).toBeInTheDocument();
expect(await within(stats).findByText("345")).toBeInTheDocument();
});
it("refreshes visible activity records every 15 seconds without following live updates", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "定时刷新运行记录" },
});
fireEvent.click(await screen.findByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
const realSetInterval = window.setInterval.bind(window);
const refreshTicks: Array<() => void> = [];
const intervalSpy = vi
.spyOn(window, "setInterval")
.mockImplementation(((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
if (timeout === 15_000 && typeof handler === "function") {
refreshTicks.push(handler as () => void);
}
return realSetInterval(handler, timeout, ...args);
}) as typeof window.setInterval);
try {
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
await screen.findByRole("heading", { level: 1, name: "运行记录" });
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce(),
);
const items = () =>
document.querySelectorAll(".activity-list__item").length;
const baseline = items();
expect(baseline).toBeGreaterThan(0);
act(() =>
agentListener?.({
requestId,
type: "tool",
callId: "interval-tool",
name: "read",
state: "running",
summary: "定时刷新工具",
}),
);
// The page keeps its snapshot until the next periodic refresh.
expect(items()).toBe(baseline);
expect(refreshTicks).toHaveLength(2);
act(() => refreshTicks.forEach((tick) => tick()));
expect(items()).toBeGreaterThan(baseline);
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2),
);
} finally {
intervalSpy.mockRestore();
}
});
it("offers only Ask and Execute in visible work mode controls", async () => {
@@ -7917,7 +8254,7 @@ describe("App", () => {
).toHaveTextContent(/^Ask$/u);
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
"placeholder",
"给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
"Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
);
expect(
within(conversationSettings).getByRole("button", {
@@ -11651,6 +11988,7 @@ describe("App", () => {
it('recovers failed image editing through settings and composer attachments without resubmitting the operation', async () => {
const conversationId = crypto.randomUUID();
const otherId = crypto.randomUUID();
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
id: crypto.randomUUID(), conversationId, messageId: crypto.randomUUID(), requestId: crypto.randomUUID(), callId: 'failed-edit',
modelProfileId: crypto.randomUUID(), modelName: 'original-image-model', modelProfileName: 'Original image connection',
@@ -11659,14 +11997,23 @@ describe("App", () => {
};
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
] }]);
] }, { id: otherId, projectId, title: 'Other image draft', updatedAt: 1, messages: [] }]);
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([source]);
const selection = deferred<ContextAttachment[]>();
vi.mocked(api.context.selectFiles).mockReturnValueOnce(selection.promise);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: '重新选择素材' }));
const composer = screen.getByLabelText('向 GoodBuddy 提问');
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(composer).toHaveFocus();
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledWith(conversationId));
fireEvent.click(screen.getByText('Other image draft').closest('button')!);
fireEvent.change(composer, { target: { value: 'Keep other image draft' } });
await act(async () => selection.resolve([source]));
expect(composer).toHaveValue('Keep other image draft');
expect(within(composer.closest<HTMLElement>('.composer')!).queryByText('replacement.png')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Failed image editing').closest('button')!);
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
expect(await within(composer.closest<HTMLElement>('.composer')!).findByText('replacement.png')).toBeVisible();
expect(api.context.selectFiles).toHaveBeenCalledOnce();
expect(run).not.toHaveBeenCalled();
@@ -13708,10 +14055,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13724,10 +14072,11 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13777,10 +14126,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13837,10 +14187,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13874,10 +14225,11 @@ describe("App", () => {
getSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13890,10 +14242,11 @@ describe("App", () => {
updateSettings: vi.fn(async () => ({
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github" as const,
modelDownloadSource: "modelscope" as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -13952,10 +14305,11 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14011,10 +14365,11 @@ describe("App", () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: "github",
modelDownloadSource: "modelscope",
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultTestApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
heartbeatEnabled: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
@@ -14096,16 +14451,16 @@ describe("App", () => {
await waitFor(() => expect(move).toBeEnabled())
fireEvent.click(move)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '知识库', '魔法笔记', '本机推理监控',
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)',
]))
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
fireEvent.dragStart(cards[3]!)
fireEvent.drop(cards[1]!)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
'监督者', '本机推理监控', '知识库', '魔法笔记',
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)',
]))
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes'])
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
@@ -14113,7 +14468,67 @@ describe("App", () => {
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记'])
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)'])
})
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
const updates = api.updates!
await updates.updateSettings({ deviceSharingEnabled: true })
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: '监督者' })
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
await screen.findByRole('button', { name: '注册本机' })
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: false })))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '对话' })).toHaveAttribute('aria-current', 'page')
await act(async () => changed(await updates.updateSettings({ deviceSharingEnabled: true })))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.getByRole('menuitem', { name: '设备共享(技术预览)' })).toBeInTheDocument()
})
it('enables device sharing from Application Center and leaves its page when disabled', async () => {
render(<App />)
await screen.findByRole('button', { name: '监督者' })
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
const row = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
expect(within(row).getByRole('button', { name: '打开' })).toBeDisabled()
fireEvent.click(within(row).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用应用' })
await waitFor(() => expect(enable).toBeEnabled())
expect(enable).not.toBeChecked()
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
fireEvent.click(enable)
await waitFor(() => expect(enable).toBeChecked())
expect(api.updates!.updateSettings).toHaveBeenCalledWith({ deviceSharingEnabled: true })
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
await waitFor(() => {
expect(screen.getByRole('heading', { name: '设备共享(技术预览)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled()
})
expect(screen.queryByRole('dialog', { name: '应用中心' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
fireEvent.click(screen.getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const disable = screen.getByRole('switch', { name: '启用应用' })
await waitFor(() => expect(disable).toBeEnabled())
fireEvent.click(disable)
await waitFor(() => expect(disable).not.toBeChecked())
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(screen.queryByRole('heading', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '注册本机' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '应用' }))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: '设置' }))
expect(await screen.findByRole('dialog', { name: '设置中心' })).toBeInTheDocument()
})
it('refreshes externally changed all-app order before editing and filters sidebar pins', async () => {
@@ -14355,6 +14770,28 @@ describe("App", () => {
expect(screen.queryByRole('button', { name: '应用设置' })).not.toBeInTheDocument()
})
it('focuses the chat composer after a guarded Magic Notes leave to a new conversation', async () => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
const note = {
id: '00000000-0000-4000-8000-000000000602', title: 'Focus note', preview: '',
entryCount: 0, pinned: false, tags: [], revision: 1, createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z', entries: [],
}
vi.mocked(api.magicNotes.list).mockResolvedValue({ notes: [note], tags: [] })
vi.mocked(api.magicNotes.get).mockResolvedValue(note)
render(<App />)
fireEvent.click(await screen.findByRole('button', { name: '魔法笔记' }))
fireEvent.click(await screen.findByRole('button', { name: /Focus note.*条记录/ }))
fireEvent.change(await screen.findByLabelText('笔记标题'), { target: { value: 'Unsaved focus title' } })
fireEvent.click(screen.getByRole('button', { name: /新建对话/ }))
const discard = await screen.findByRole('button', { name: '放弃草稿并切换' })
await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)) })
// A real pointer click moves focus to the confirmation button before the leave completes.
discard.focus()
fireEvent.click(discard)
await waitFor(() => expect(screen.getByRole('textbox', { name: '向 GoodBuddy 提问' })).toHaveFocus())
})
it.each(['title', 'text'])('guards real App navigation to multiple apps and discards only after confirmation (%s)', async (draft) => {
await api.updates!.updateSettings({ magicNotesEnabled: true })
const note = {
+780 -573
View File
File diff suppressed because it is too large Load Diff
+39 -14
View File
@@ -48,6 +48,31 @@ afterEach(async () => {
})
describe('Application Center', () => {
it('offers device sharing enablement without pinning and waits for confirmed settings', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} />)
expect(isApplicationEnabled(undefined, 'device-sharing')).toBe(false)
const card = screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
expect(within(card).getByRole('button', { name: '打开' })).toBeDisabled()
fireEvent.click(within(card).getByRole('button', { name: '设备共享(技术预览) 应用设置' }))
const enable = screen.getByRole('switch', { name: '启用应用' })
expect(enable).not.toBeChecked()
expect(screen.queryByRole('switch', { name: '常驻左侧菜单' })).not.toBeInTheDocument()
fireEvent.click(enable)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: true })
expect(enable).not.toBeChecked()
rerender(<ApplicationCenter {...handlers} pending />)
expect(enable).toBeDisabled()
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, deviceSharingEnabled: true }} />)
expect(enable).toBeChecked()
fireEvent.click(enable)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ deviceSharingEnabled: false })
fireEvent.click(screen.getByRole('button', { name: '返回应用中心' }))
fireEvent.click(within(screen.getByText('设备共享(技术预览)', { exact: true }).closest('article')!).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
})
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
await changeUiLocale(locale)
const onUpdate = vi.fn(async () => true)
@@ -102,7 +127,7 @@ describe('Application Center', () => {
fireEvent.drop(card)
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
...defaultApplicationNavigation,
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference'],
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'],
} })
})
@@ -214,7 +239,7 @@ describe('Application Center', () => {
fireEvent.dragStart(notes)
fireEvent.drop(inference)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
} })
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).not.toHaveBeenCalled()
@@ -238,9 +263,9 @@ describe('Application Center', () => {
const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
expect(moveUp.closest('article')).not.toBeNull()
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
@@ -248,18 +273,18 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
expect(screen.getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控'])
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
rerender(<ApplicationCenter {...handlers} settings={{
...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'] },
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
}} />)
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记'])
expect(screen.getByRole('button', { name: '下移 魔法笔记' })).toBeDisabled()
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)'])
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
@@ -281,7 +306,7 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
})
@@ -322,12 +347,12 @@ describe('Application Center', () => {
fireEvent.change(screen.getByRole('searchbox'), { target: { value: '推理' } })
const card = screen.getByRole('article')
expect(card).toHaveAttribute('draggable', 'false')
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeDisabled()
expect(within(card).getByRole('button', { name: '下移 本机推理监控' })).toBeEnabled()
fireEvent.click(within(card).getByRole('button', { name: '上移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: {
...pinnedSettings.applicationNavigation,
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes'],
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
},
})
})
@@ -342,7 +367,7 @@ describe('Application Center', () => {
})
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(screen.getAllByRole('article')).toHaveLength(4)
expect(screen.getAllByRole('article')).toHaveLength(5)
})
it('edits all notes preferences through the shared settings form', () => {
+7 -4
View File
@@ -11,6 +11,7 @@ import {
ArrowLeft,
ArrowUp,
Cpu,
Network,
HeartPulse,
Library,
Settings,
@@ -31,6 +32,7 @@ import { PageHeader, SegmentedControl } from './WorkspacePrimitives'
import './application-center.css'
export const applicationDefinitions = {
'device-sharing': { icon: Network, title: 'sharing.title', enabled: 'deviceSharingEnabled' },
'magic-notes': {
icon: Sparkles,
title: 'navigation.magicNotes',
@@ -57,6 +59,7 @@ export function isApplicationEnabled(
id: BuiltInApplicationId,
): boolean {
if (id === 'knowledge') return true
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
return (
settings !== undefined &&
@@ -119,7 +122,7 @@ export function ApplicationSettingsView({
/>
<span>{t('applications.enable')}</span>
</label>
<div className="inline-help-label">
{id !== 'device-sharing' && <div className="inline-help-label">
<label className="toggle-row">
<input
role="switch"
@@ -139,8 +142,8 @@ export function ApplicationSettingsView({
<span>{t('applications.pin')}</span>
</label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>
<p className="settings-notice">{t('applications.disableHelp')}</p>
</div>}
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
@@ -466,7 +469,7 @@ export function ApplicationCenter({
>
{t('applications.open')}
</button>
{!alwaysShown && (
{id !== 'knowledge' && (
<button
className="secondary-button"
type="button"
+13
View File
@@ -22,6 +22,19 @@ function Harness({ value = settings, pending = false, error, onOpen = vi.fn(), o
}
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('only shows device sharing when explicitly enabled and removes it on disable', () => {
const onOpen = vi.fn()
const { rerender } = render(<Harness onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
rerender(<Harness value={{ ...settings, deviceSharingEnabled: true }} onOpen={onOpen} />)
fireEvent.click(screen.getByRole('menuitem', { name: '设备共享(技术预览)' }))
expect(onOpen).toHaveBeenCalledWith('device-sharing')
fireEvent.click(screen.getByText('Launcher'))
rerender(<Harness value={{ ...settings, deviceSharingEnabled: false }} onOpen={onOpen} />)
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
})
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
+4 -3
View File
@@ -60,10 +60,11 @@ it('keeps attachment actions icon-only and named, and restores keyboard focus af
})
it('keeps queued attachments read-only and exposes no action for legacy attachments without assets', async () => {
const { rerender } = render(<AttachmentActions attachment={attachment} readOnly />)
const { rerender } = render(<AttachmentActions attachment={{ ...attachment, sendMode: 'text' }} conversationId="conversation" readOnly />)
fireEvent.click(screen.getByRole('button'))
await screen.findByText('解析来源:HTTP PaddleOCR-VL(远程服务)')
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
expect(screen.queryByText(/解析来源/)).not.toBeInTheDocument()
expect(window.goodbuddy.documentParsing!.getSnapshot).not.toHaveBeenCalled()
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
rerender(<AttachmentActions attachment={{ ...attachment, resourceId: undefined }} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
+5 -5
View File
@@ -31,24 +31,24 @@ export function AttachmentActions({ attachment, conversationId, onBusyChange, re
return <>
<button type="button" className="icon-button attachment-action" title="更多附件操作" data-tooltip="更多附件操作" ref={anchor} aria-haspopup="menu" aria-expanded={menu} aria-controls={menu ? id : undefined}
aria-label={`更多附件操作:${attachment.name}`} disabled={Boolean(operation)} onClick={() => {
if (!menu) {
if (!menu && !readOnly) {
setProvider('正在读取已保存的解析来源')
void window.goodbuddy.documentParsing!.getSnapshot().then((snapshot) => setProvider(snapshot.settings.ocrProvider === 'paddleocr-vl' ? '解析来源:HTTP PaddleOCR-VL(远程服务)' : '解析来源:本地 OCR'), () => setProvider('来源读取失败;操作仍使用已保存的解析设置'))
}
setMenu((value) => !value)
}}><Ellipsis size={16} aria-hidden="true" /></button>
{menu && <AnchoredMenu anchorRef={anchor} id={id} label={`附件操作:${attachment.name}`} onClose={() => setMenu(false)}>
<small>{provider}</small>
{!readOnly && <small>{provider}</small>}
<button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.openOriginal(attachment.resourceId!))}>打开原文件:{attachment.originalName ?? attachment.name}</button>
{conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{!readOnly && conversationId && <button type="button" role="menuitem" onClick={() => run((operationId) => window.goodbuddy.context.reparseDraft(conversationId, attachment.id, operationId))}>
{attachment.sendMode ? '提取图片文字' : '使用当前设置重新解析'}:{attachment.originalName ?? attachment.name}
</button>}
{conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && conversationId && attachment.sendMode === 'text' && <button type="button" role="menuitem" onClick={() => run(() => window.goodbuddy.context.sendOriginal(conversationId, attachment.id))}>改为发送原图:{attachment.originalName}</button>}
{!readOnly && !conversationId && target?.activeId && <button type="button" role="menuitem" onClick={() => run(async (operationId) => {
await window.goodbuddy.context.copyToDraft(target.activeId!, attachment.resourceId!, operationId)
target.notify('已按当前设置解析并添加到目标会话草稿')
})}>{attachment.sendMode ? '添加到当前草稿并提取文字' : '添加到当前草稿重新解析'}:{attachment.originalName ?? attachment.name}</button>}
<button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>
{!readOnly && <button type="button" role="menuitem" onClick={() => { setMenu(false); void window.goodbuddy.localInference.openSettings('document-parsing') }}>前往文档解析设置</button>}
</AnchoredMenu>}
{operation && <span role="status">正在处理附件<button type="button" className="secondary-button" onClick={() => void window.goodbuddy.context.cancelParsing(operation)}>取消解析</button></span>}
{error && <span role="alert">{error}</span>}
@@ -58,7 +58,6 @@ export function CapabilitiesAndToolsSettingsSection({
}
]}
value={activeTab}
variant="segmented"
/>
</div>
}
+82 -78
View File
@@ -1,5 +1,6 @@
import { ArrowDown, FileText, Sparkles } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
@@ -34,7 +35,7 @@ export type ChatScrollSnapshot = {
scrollTop: number;
};
export function ChatHistoryPane({
export const ChatHistoryPane = memo(function ChatHistoryPane({
onAddToNote,
noteMessageNavigation,
onOpenImageModelSettings,
@@ -103,6 +104,7 @@ export function ChatHistoryPane({
const { t } = useTranslation("app");
const headingId = `chat-heading-${conversation.id}`;
const scrollRef = useRef<HTMLElement>(null);
const contextRef = useRef<HTMLDivElement>(null);
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
@@ -129,6 +131,29 @@ export function ChatHistoryPane({
);
const hiddenMessageCount = visibleMessageStartIndex;
useLayoutEffect(() => {
const context = contextRef.current;
const scrollContainer = scrollRef.current;
const content = scrollContainer?.querySelector<HTMLElement>('.chat-content');
if (!active || !context || !scrollContainer || !content) return;
const measure = (): void => {
// The fixed context strip also needs space inside the scrolling content.
context.parentElement?.style.setProperty(
'--chat-context-height', `${context.getBoundingClientRect().height}px`,
);
};
measure();
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => {
measure();
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
});
observer.observe(context);
observer.observe(content);
return () => observer.disconnect();
}, [active]);
useEffect(() => {
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
@@ -231,29 +256,6 @@ export function ChatHistoryPane({
}
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
useLayoutEffect(() => {
if (!active || typeof ResizeObserver !== "function") {
return;
}
const scrollContainer = scrollRef.current;
const messageList =
scrollContainer?.querySelector<HTMLElement>(".message-list");
if (!scrollContainer || !messageList) {
return;
}
const observer = new ResizeObserver(() => {
if (restorePendingRef.current || !pinnedToBottomRef.current) {
return;
}
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
});
observer.observe(messageList);
return () => observer.disconnect();
}, [active]);
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
@@ -329,7 +331,7 @@ export function ChatHistoryPane({
<h1 className="sr-only" id={headingId}>
{t("chat.heading")}
</h1>
{taskStrip}
<div className="chat-history-context" ref={contextRef}>{taskStrip}</div>
<section
aria-labelledby={headingId}
className="chat"
@@ -337,59 +339,61 @@ export function ChatHistoryPane({
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
<div className="chat-content">
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
</div>
</section>
{active && showScrollToBottom && (
<button
@@ -405,4 +409,4 @@ export function ChatHistoryPane({
)}
</div>
);
}
});
+3 -3
View File
@@ -4,7 +4,7 @@ import {
CircleAlert,
ClockFading
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
@@ -31,7 +31,7 @@ type ConversationTaskStripProps = {
tasks: AssistantTask[]
}
export function ConversationTaskStrip({
export const ConversationTaskStrip = memo(function ConversationTaskStrip({
conversationMode,
locale,
onRemoveSchedule,
@@ -213,4 +213,4 @@ export function ConversationTaskStrip({
)}
</section>
)
}
})
@@ -0,0 +1,64 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { DeviceSharingPage } from './DeviceSharingPage'
import type { DeviceSharingApi, SharingCatalog, SharingPublicationDraft } from '../../shared/device-sharing-contracts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
const settings = { deviceId: 'mine', name: 'My desktop', serverUrl: 'http://127.0.0.1:8787' }
function setup() {
const catalog: SharingCatalog = { devices: [], publications: [] }
const sharing = {
getSettings: vi.fn(async () => settings), saveSettings: vi.fn(async input => ({ ...settings, ...input })),
getCatalog: vi.fn(async () => structuredClone(catalog)),
registerDevice: vi.fn(async () => {
const device = { id: 'mine', name: 'My desktop', platform: 'win32', appVersion: '1', registeredAt: 'today', updatedAt: 'today' }
catalog.devices.push(device); return device
}),
publish: vi.fn(async (input: SharingPublicationDraft) => {
const item = { ...input, id: 'publication', deviceId: 'mine', status: 'published' as const, createdAt: 'today', updatedAt: 'today' }
catalog.publications.push(item); return item
}),
revoke: vi.fn(async () => { catalog.publications[0]!.status = 'revoked'; return catalog.publications[0]! })
} satisfies DeviceSharingApi
vi.stubGlobal('goodbuddy', { sharing })
const notify = vi.fn()
render(<DeviceSharingPage notify={notify} />)
return { sharing, catalog, notify }
}
it('registers, publishes independent knowledge flags, revokes and refreshes actual returned rows', async () => {
const { sharing, notify } = setup()
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
expect(screen.getByRole('button', { name: '发布元数据' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '注册本机' }))
await waitFor(() => expect(screen.queryByText('暂无注册设备')).not.toBeInTheDocument())
fireEvent.change(screen.getByLabelText('条目名称'), { target: { value: 'Shared knowledge' } })
fireEvent.change(screen.getByLabelText('类型'), { target: { value: 'knowledge' } })
fireEvent.change(screen.getByLabelText('来源模式'), { target: { value: 'server' } })
fireEvent.click(screen.getByRole('checkbox', { name: '搜索' }))
fireEvent.click(screen.getByRole('checkbox', { name: '下载' }))
await waitFor(() => expect(screen.getByRole('button', { name: '发布元数据' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '发布元数据' }))
await waitFor(() => expect(screen.getByRole('button', { name: '撤销发布' })).toBeEnabled())
expect(sharing.publish).toHaveBeenCalledWith(expect.objectContaining({ sourceMode: 'server', permissions: { search: true, read: false, download: true } }))
const row = screen.getByText('Shared knowledge').closest('article')!
expect(within(row).getByText(/搜索: 允许 · 读取: 不允许 · 下载: 允许/)).toBeInTheDocument()
fireEvent.click(within(row).getByRole('button', { name: '撤销发布' }))
fireEvent.click(screen.getByRole('button', { name: '确认撤销发布' }))
await waitFor(() => { expect(screen.getByText('已撤销')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '刷新列表' })).toBeEnabled() })
expect(sharing.revoke).toHaveBeenCalledWith('publication')
expect(notify).toHaveBeenCalledWith({ tone: 'success', message: '发布条目已撤销' })
})
it('preserves unsaved input on failure and does not offer revocation for other device IDs', async () => {
const { sharing, catalog, notify } = setup()
await waitFor(() => expect(screen.getByRole('button', { name: '注册本机' })).toBeEnabled())
catalog.publications.push({ name: 'Other', id: 'other', deviceId: 'theirs', description: '', kind: 'capability', sourceMode: 'device', permissions: { search: false, read: false, download: false }, status: 'published', createdAt: 'today', updatedAt: 'today' })
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }))
await screen.findByText('Other')
expect(screen.queryByRole('button', { name: '撤销发布' })).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByLabelText('设备名称')).toBeEnabled())
sharing.saveSettings.mockRejectedValueOnce(new Error('Disk full'))
fireEvent.change(screen.getByLabelText('设备名称'), { target: { value: 'My edit' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ tone: 'error', message: 'Error: Disk full' })))
expect(screen.getByLabelText('设备名称')).toHaveValue('My edit')
expect(screen.getByRole('button', { name: '注册本机' })).toBeDisabled()
})
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SharingCatalog, SharingPublicationDraft, SharingSettings } from '../../shared/device-sharing-contracts'
import { sharingSettingsInputSchema } from '../../shared/device-sharing-contracts'
import { EmptyState, PageHeader, PageShell } from './WorkspacePrimitives'
import type { AppNotificationInput } from './notifications'
import './device-sharing.css'
const emptyDraft: SharingPublicationDraft = {
name: '', description: '', kind: 'capability', sourceMode: 'device',
permissions: { search: false, read: false, download: false }
}
export function DeviceSharingPage({ notify }: { notify: (input: AppNotificationInput) => void }): React.JSX.Element {
const { t } = useTranslation('app')
const [settings, setSettings] = useState<SharingSettings>()
const [name, setName] = useState('')
const [serverUrl, setServerUrl] = useState('')
const [catalog, setCatalog] = useState<SharingCatalog>()
const [draft, setDraft] = useState(emptyDraft)
const [busy, setBusy] = useState(true)
const [error, setError] = useState('')
const [reload, setReload] = useState(0)
const [revokeId, setRevokeId] = useState<string>()
const pending = useRef(false)
useEffect(() => {
let active = true
void window.goodbuddy.sharing.getSettings().then(async next => {
if (!active) return
setSettings(next); setName(next.name); setServerUrl(next.serverUrl)
const snapshot = await window.goodbuddy.sharing.getCatalog()
if (active) { setCatalog(snapshot); setError('') }
}).catch((reason: unknown) => { if (active) setError(String(reason)) })
.finally(() => { if (active) setBusy(false) })
return () => { active = false }
}, [reload])
const refresh = async (): Promise<void> => {
try {
setCatalog(await window.goodbuddy.sharing.getCatalog())
setError('')
} catch (reason) {
setError(String(reason))
}
}
const run = async (action: () => Promise<unknown>, message?: string): Promise<void> => {
if (pending.current) return
pending.current = true; setBusy(true)
try {
await action()
if (message) notify({ tone: 'success', message })
} catch (reason) {
notify({ tone: 'error', message: String(reason), dedupeKey: 'device-sharing-action' })
} finally { pending.current = false; setBusy(false) }
}
const dirty = settings && (name !== settings.name || serverUrl !== settings.serverUrl)
const registered = Boolean(settings && catalog?.devices.some(device => device.id === settings.deviceId))
const canPublish = registered && !dirty && !error && !busy
const validSettings = sharingSettingsInputSchema.safeParse({ name, serverUrl }).success
return <PageShell variant="standard">
<PageHeader headingId="device-sharing-title" title={t('sharing.title')} scope={{ kind: 'global' }} description={t('sharing.boundary')}
actions={<button className="secondary-button" disabled={busy} onClick={() => {
if (!settings) { setBusy(true); setReload(value => value + 1) }
else void run(refresh)
}}>{t('sharing.refresh')}</button>} />
<div className="device-sharing" aria-busy={busy}>
{busy && <p role="status">{t('sharing.working')}</p>}
{error && <div role="alert" className="settings-warning">{t('sharing.loadFailed')} {error}</div>}
{settings && <section aria-labelledby="sharing-settings">
<h2 id="sharing-settings">{t('sharing.thisDevice')}</h2>
<p>{t('sharing.deviceId')}: <code>{settings.deviceId}</code></p>
<form onSubmit={event => { event.preventDefault(); void run(async () => {
const next = await window.goodbuddy.sharing.saveSettings({ name, serverUrl })
setSettings(next); setName(next.name); setServerUrl(next.serverUrl); setCatalog(undefined)
await refresh()
}, t('sharing.saved')) }}>
<label className="field"><span>{t('sharing.serverUrl')}</span><input type="url" required value={serverUrl} disabled={busy} onChange={event => setServerUrl(event.target.value)} /></label>
<label className="field"><span>{t('sharing.deviceName')}</span><input required maxLength={200} value={name} disabled={busy} onChange={event => setName(event.target.value)} /></label>
<div className="device-sharing__actions">
<button className="secondary-button" disabled={busy || !dirty || !validSettings}>{t('sharing.save')}</button>
<button type="button" className="secondary-button" disabled={busy || Boolean(dirty)} onClick={() => void run(async () => {
await window.goodbuddy.sharing.registerDevice(); await refresh()
}, t('sharing.registered'))}>{t('sharing.register')}</button>
</div>
{dirty && <p>{t('sharing.saveFirst')}</p>}
</form>
</section>}
<section aria-labelledby="sharing-devices">
<h2 id="sharing-devices">{t('sharing.devices')}</h2>
{catalog?.devices.length === 0 && <EmptyState title={t('sharing.noDevices')} description={t('sharing.registerFirst')} />}
{catalog?.devices.map(device => <article key={device.id}>
<h3>{device.name} {device.id === settings?.deviceId && <small>({t('sharing.thisDevice')})</small>}</h3>
<p>{device.platform} · {device.appVersion} · <code>{device.id}</code></p>
<p>{t('sharing.updatedAt')}: {device.updatedAt}</p>
</article>)}
</section>
<section aria-labelledby="sharing-publish">
<h2 id="sharing-publish">{t('sharing.publishMetadata')}</h2>
<p>{t('sharing.sourceHelp')}</p>
{!registered && <p>{t('sharing.registerFirst')}</p>}
<form onSubmit={event => { event.preventDefault(); if (!canPublish) return; void run(async () => {
await window.goodbuddy.sharing.publish(draft); setDraft(emptyDraft); await refresh()
}, t('sharing.publishedNotice')) }}>
<fieldset disabled={busy}>
<label className="field"><span>{t('sharing.name')}</span><input required maxLength={200} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} /></label>
<label className="field"><span>{t('sharing.description')}</span><textarea maxLength={4000} value={draft.description} onChange={event => setDraft({ ...draft, description: event.target.value })} /></label>
<label className="field"><span>{t('sharing.kind')}</span><select value={draft.kind} onChange={event => setDraft({ ...draft, kind: event.target.value as SharingPublicationDraft['kind'] })}>
<option value="capability">{t('sharing.capability')}</option><option value="knowledge">{t('sharing.knowledge')}</option>
</select></label>
<label className="field"><span>{t('sharing.sourceMode')}</span><select value={draft.sourceMode} onChange={event => setDraft({ ...draft, sourceMode: event.target.value as SharingPublicationDraft['sourceMode'] })}>
<option value="device">{t('sharing.device')}</option><option value="server">{t('sharing.server')}</option>
</select></label>
{draft.kind === 'knowledge' && <fieldset><legend>{t('sharing.permissions')}</legend>
{(['search', 'read', 'download'] as const).map(permission => <label key={permission} className="device-sharing__permission"><input type="checkbox" checked={draft.permissions[permission]} onChange={event => setDraft({ ...draft, permissions: { ...draft.permissions, [permission]: event.target.checked } })} />{t(`sharing.${permission}`)}</label>)}
</fieldset>}
</fieldset>
<p>{t('sharing.target')}: {settings?.serverUrl} · {settings?.name}</p>
<button className="primary-button" disabled={!canPublish || !draft.name.trim()}>{t('sharing.publishMetadata')}</button>
</form>
</section>
<section aria-labelledby="sharing-publications">
<h2 id="sharing-publications">{t('sharing.publications')}</h2>
{catalog?.publications.length === 0 && <EmptyState title={t('sharing.noPublications')} description={t('sharing.publishHelp')} />}
{catalog?.publications.map(item => <article key={item.id}>
<h3>{item.name} <small>{t(`sharing.${item.status}`)}</small></h3>
<p>{item.description}</p>
<p>{t(`sharing.${item.kind}`)} · {t('sharing.sourceMode')}: {t(`sharing.${item.sourceMode}`)}</p>
<p>{t('sharing.deviceId')}: <code>{item.deviceId}</code> · {t('sharing.updatedAt')}: {item.updatedAt}</p>
{item.kind === 'knowledge' && <p>{(['search', 'read', 'download'] as const).map(key => `${t(`sharing.${key}`)}: ${t(item.permissions[key] ? 'sharing.yes' : 'sharing.no')}`).join(' · ')}</p>}
{item.deviceId === settings?.deviceId && item.status === 'published' && (revokeId === item.id ? <div>
<p>{t('sharing.revokeHelp')}</p><div className="device-sharing__actions">
<button className="secondary-button" disabled={busy} onClick={() => setRevokeId(undefined)}>{t('sharing.cancel')}</button>
<button className="danger-solid" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => void run(async () => {
await window.goodbuddy.sharing.revoke(item.id); setRevokeId(undefined); await refresh()
}, t('sharing.revokedNotice'))}>{t('sharing.confirmRevoke')}</button>
</div>
</div> : <button className="danger-ghost" disabled={busy || Boolean(dirty) || Boolean(error)} onClick={() => setRevokeId(item.id)}>{t('sharing.revoke')}</button>)}
</article>)}
</section>
</div>
</PageShell>
}
@@ -268,7 +268,7 @@ describe('EmbeddingSettingsSection', () => {
).not.toHaveAttribute('value')
})
it('keeps an unavailable source explicit and opens General settings', () => {
it('keeps an unavailable source explicit and opens source settings', () => {
const onOpenModelDownloadSourceSettings = vi.fn()
renderSection({
models: {
@@ -298,7 +298,7 @@ describe('EmbeddingSettingsSection', () => {
})
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '前往通用设置' })
screen.getByRole('button', { name: '模型下载源' })
)
expect(onOpenModelDownloadSourceSettings).toHaveBeenCalledOnce()
})
+49 -5
View File
@@ -74,9 +74,10 @@ const todo: MagicTodoItem = {
const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' }
const settings: ApplicationSettings = {
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation,
localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
localInferenceEnabled: true, deviceSharingEnabled: false, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false,
magicNotesEnabled: true, magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', magicNoteCanvasPageCount: 1
}
@@ -517,7 +518,11 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*flex: 0 0 auto/)
expect(css).toMatch(/\.magic-notes-page #magic-library-switch\s*\{[^}]*width: fit-content/)
const switchButton = screen.getByRole('button', { name: '切换到待办' })
expect(screen.getByRole('region', { name: '笔记列表' })).toContainElement(screen.getByRole('button', { name: '新建笔记' }))
// New note sits in the page header, directly after the library switch; the list heading has no + button.
const newNoteButton = screen.getByRole('button', { name: '新建笔记' })
expect(newNoteButton.closest('.page-header__actions')).not.toBeNull()
expect(switchButton.nextElementSibling).toBe(newNoteButton)
expect(container.querySelector('#magic-library-panel-notes .magic-notes-pane-heading .icon-button')).toBeNull()
expect(switchButton.querySelector('.lucide-list-todo')).toBeInTheDocument()
switchButton.focus()
fireEvent.click(switchButton)
@@ -526,6 +531,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(screen.getByRole('region', { name: '待办列表' })).toBeVisible()
expect(container.querySelector('.magic-notes-layout')).not.toHaveClass('magic-notes-layout--detail')
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument()
expect(switchButton.nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
await waitFor(() => expect(switchButton).toHaveFocus())
fireEvent.click(switchButton)
expect(screen.getByRole('button', { name: '切换到待办' })).toBe(switchButton)
@@ -553,6 +559,12 @@ describe('MagicNotesWorkspace overview navigation', () => {
expect(screen.getByRole('separator', { name: '调整笔记列表宽度' })).toBeVisible()
// Back to overview lives in the page header's top-right actions while a note is open.
expect(screen.getByRole('button', { name: '返回总览' }).closest('.page-header__actions')).not.toBeNull()
expect(screen.getByRole('button', { name: '返回总览' }).nextElementSibling).toBe(screen.getByRole('button', { name: '新建笔记' }))
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
fireEvent.click(screen.getByRole('button', { name: '新建笔记' }))
expect(within(overview).getByLabelText('笔记标题')).toBeVisible()
expect(within(overview).getByLabelText('笔记标题')).toHaveFocus()
fireEvent.click(within(overview).getByRole('button', { name: '取消' }))
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
expect(screen.getByRole('searchbox')).toBeVisible()
expect(screen.getByRole('button', { name: '新建笔记' })).toBeVisible()
@@ -561,7 +573,6 @@ describe('MagicNotesWorkspace overview navigation', () => {
const css = readFileSync('src/renderer/src/styles.css', 'utf8')
expect(css).toMatch(/\.magic-notes-layout--detail\s*\{\s*border: 0;\s*border-radius: 0;/)
expect(css).toMatch(/\.magic-notes-stream-pane\s*\{[^}]*padding: 0 var\(--magic-note-stream-gutter\)/)
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
back()
expect(overview).toBeVisible()
expect(screen.getByRole('heading', { level: 1, name: '魔法笔记' })).toBeVisible()
@@ -811,6 +822,39 @@ describe('MagicNotesWorkspace overview navigation', () => {
await waitFor(() => expect(createEntry).toHaveBeenCalledWith({ noteId, content }))
})
it('cancels a new entry only after confirming that its draft will be discarded', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
newEntry()
const composer = document.querySelector('.magic-note-composer')!
fireEvent.click(screen.getByTestId('magic-note-editor'))
const cancel = within(composer as HTMLElement).getByRole('button', { name: '取消' })
// fireEvent.click does not focus like a native click; the modal must restore its actual opener.
cancel.focus()
fireEvent.click(cancel)
expect(await screen.findByRole('alertdialog')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
await waitFor(() => expect(screen.getByTestId('magic-note-editor')).toHaveFocus())
expect(document.querySelector('.magic-note-composer')).toHaveClass('magic-note-composer--active')
cancel.focus()
fireEvent.click(cancel)
fireEvent.click(await screen.findByRole('button', { name: '放弃草稿并切换' }))
await waitFor(() => expect(document.querySelector('.magic-note-composer')).not.toHaveClass('magic-note-composer--active'))
await waitFor(() => expect(document.getElementById(`magic-note-entry-${entryId}`)).toHaveFocus())
expect(createEntry).not.toHaveBeenCalled()
})
it('returns focus to the entry edit button after cancelling an inline edit', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await openNote()
const article = document.getElementById(`magic-note-entry-${entryId}`)!
fireEvent.click(within(article).getByRole('button', { name: '编辑' }))
await within(article).findByTestId('magic-note-editor')
fireEvent.click(within(article).getByRole('button', { name: '取消' }))
await waitFor(() => expect(within(article).getByRole('button', { name: '编辑' })).toHaveFocus())
})
it('keeps todo filters and search when opening its source note and returning', async () => {
render(<MagicNotesWorkspace onNotify={onNotify} />)
await screen.findByText(detail.title)
@@ -1644,9 +1688,9 @@ describe('MagicNotesWorkspace canvas integration', () => {
showAi()
const header = document.querySelector('.magic-note-composer__header')!
const actions = header.querySelector('.magic-note-canvas-actions')!
expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '保存记录'])
expect(Array.from(actions.querySelectorAll('button'), (button) => button.textContent)).toEqual(['分析画布草稿', '取消', '保存记录'])
expect(header.nextElementSibling).toBe(screen.getByTestId('canvas-editor'))
expect(screen.queryByRole('button', { name: '取消' })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: '取消' })).toHaveLength(1)
expect(document.querySelector('.magic-note-composer > footer')).toBeNull()
expect(screen.queryByText(/不随笔画自动请求|保存后自动分析画布/)).not.toBeInTheDocument()
// An empty flush permits switching to the saved record without a dirty draft.
+61 -19
View File
@@ -101,6 +101,7 @@ type DraftSwitchTarget =
| { kind: 'select-entry'; entry: MagicNoteEntry }
| { kind: 'entry-type'; value: 'text' | 'canvas' }
| { kind: 'cancel-edit' }
| { kind: 'cancel-new-entry' }
| { kind: 'overview' }
| { kind: 'library-view'; value: LibraryView }
| { kind: 'create-note'; title: string }
@@ -1125,11 +1126,40 @@ export function MagicNotesWorkspace({
if (target.kind === 'entry-type') {
discardComposerDraft()
setEntryType(target.value)
// The composer remounts with the new type; move focus into the new editor.
requestAnimationFrame(() => {
if (target.value === 'canvas' && composerCanvasRef.current) { composerCanvasRef.current.focus(); return }
composerRef.current?.querySelector<HTMLElement>('.ql-editor, [data-testid="magic-note-editor"]')?.focus({ preventScroll: true })
})
return
}
if (target.kind === 'cancel-new-entry') {
discardComposerDraft()
setEntryType('text')
clearValidation('new-entry')
// Move focus out of the composer after any confirmation restores it, so the
// composer collapses without dropping focus onto the document body.
const fallbackEntryId = detail?.entries.some((entry) => entry.id === selectedEntryId) ? selectedEntryId : detail?.entries.at(-1)?.id
requestAnimationFrame(() => {
const active = document.activeElement
if (active instanceof HTMLElement && active !== document.body && !composerRef.current?.contains(active)) return
const entry = fallbackEntryId ? document.getElementById(`magic-note-entry-${fallbackEntryId}`) : null
if (entry) { entry.focus({ preventScroll: true }); return }
if (active instanceof HTMLElement) active.blur()
})
return
}
if (target.kind === 'cancel-edit') {
const editedEntryId = editingEntry?.id
discardEditingDraft()
setSelectedEntryId((current) => detail?.entries.some((entry) => entry.id === current) ? current : detail?.entries.at(-1)?.id ?? '')
// The inline editor unmounts; return focus to the entry that was being edited.
requestAnimationFrame(() => {
const article = editedEntryId ? document.getElementById(`magic-note-entry-${editedEntryId}`) : null
if (!article) return
const editButton = article.querySelector<HTMLButtonElement>('button[data-entry-edit]:not(:disabled)')
;(editButton ?? article).focus({ preventScroll: true })
})
return
}
if (target.kind === 'overview') {
@@ -1183,10 +1213,13 @@ export function MagicNotesWorkspace({
void loadDetail(target.noteId, target.entryId)
},
[
clearValidation,
createNote,
detail,
discardComposerDraft,
discardEditingDraft,
editingEntry?.id,
selectedEntryId,
focusSwitchTarget,
loadDetail,
libraryView,
@@ -2178,22 +2211,26 @@ export function MagicNotesWorkspace({
{libraryView === 'notes' ? <ListTodo aria-hidden="true" size={15} /> : <BookOpen aria-hidden="true" size={15} />}
{t(libraryView === 'notes' ? 'actions.switchToTodos' : 'actions.switchToNotes')}
</button>
{libraryView === 'todos' && <button
id="magic-note-new"
className="primary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => {
setValidation(undefined)
setLibraryView('notes')
setCreating(true)
}}
>
<Plus aria-hidden="true" size={15} />
{t('actions.newNote')}
</button>}
</>
)}
<button
id="magic-note-new"
className="primary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => {
setValidation(undefined)
setLibraryView('notes')
setCreating(true)
if (detailView) {
if (isNarrowWorkspace) void requestDraftSwitch({ kind: 'overview' })
else setNotesPaneOpen(true)
}
}}
>
<Plus aria-hidden="true" size={15} />
{t('actions.newNote')}
</button>
</>
}
help={t('page.description')}
@@ -2265,11 +2302,6 @@ export function MagicNotesWorkspace({
disabled={Boolean(busy)}
onClick={() => setTagManagerOpen(true)}
/>
<button id="magic-note-new" type="button" className="icon-button"
disabled={Boolean(busy)} aria-label={t('actions.newNote')} title={t('actions.newNote')}
onClick={() => { setValidation(undefined); setCreating(true) }}>
<Plus size={15} />
</button>
</div>
<label className="magic-notes-search">
<span className="sr-only">{t('notes.searchLabel')}</span>
@@ -2813,6 +2845,7 @@ export function MagicNotesWorkspace({
</div>
{entryType === 'canvas' && <div className="magic-note-canvas-actions">
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void analyzeCanvasDraft(false)}>{t('canvas.analyzeDraft')}</button>
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}>{t('actions.cancel')}</button>
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => void saveEntry()}>{t('actions.saveEntry')}</button>
</div>}
</div>
@@ -2869,6 +2902,14 @@ export function MagicNotesWorkspace({
</p>
)}
{entryType === 'text' && <footer style={{ justifyContent: 'flex-end' }}>
<button
className="secondary-button"
disabled={Boolean(busy)}
type="button"
onClick={() => void requestDraftSwitch({ kind: 'cancel-new-entry' })}
>
{t('actions.cancel')}
</button>
<button
className="primary-button"
disabled={Boolean(busy)}
@@ -2965,6 +3006,7 @@ export function MagicNotesWorkspace({
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
disabled={Boolean(busy) || editingEntry?.id === entry.id}
data-entry-edit=""
type="button"
onClick={() =>
requestDraftSwitch({
+97 -1
View File
@@ -18,9 +18,12 @@ import { changeUiLocale } from './i18n'
import {
InlineMarkdown,
MarkdownRenderer,
normalizeLatexDelimiters
normalizeLatexDelimiters,
splitMarkdownSegments,
UnsegmentedMarkdownRenderer
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
@@ -721,3 +724,96 @@ ${'A'.repeat(20_001)}
await changeUiLocale('zh-CN')
})
})
describe('segmented Markdown rendering', () => {
afterEach(() => cleanup())
const prose = ('A growing paragraph with **emphasis**, `inline code`, and [a link](https://example.com).\n\n' +
'- First item\n- Second item\n\n> A quoted explanation.\n\n').repeat(8)
const segmentationCorpus = [
'# Heading\n\n**bold** and [link](https://example.com)\n- [x] ready\n',
'Inline \\(a+b\\), $x$ and \\\\(escaped\\).\n$x^2$\n\\(y\\)\n\\[z\\]\n\\[\na+b\n\\]\n',
'`\\(code\\)` ``a ` \\(b\\)`` and \\(x\\)\n```tex\n\\(x\\)\n```\n~~~\n$y$\n~~~\n',
'````tex\n```\n\\[x\\]\n````\n ~~~txt\n\\(y\\)\n ~~~~\n\\(z\\)\n',
'a\r\nb\r\n$x$\r\n```txt\r\n\\(code\\)\r\n```\r\nend\r',
'partial \\(x + \\) then `code \\( and `` and \\[\n\\',
'\n\nplain\rtext\r\n\n $indented$\n \\(indented\\)\n',
'# Streaming answer\n\n' + prose,
prose.slice(0, 600) + '\n\\(x^2 + y^2\\)\n\n```ts\nconst value = "\\(literal\\)"\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n',
'# 标题\n\n第一段 **粗体**。\n\n第二段 `code`。\n\n## 小节\n\n最后一段。',
'- a\n- b\n\n- c\n\nafter list\n\n1. one\n2. two\n\n3) three\n\npara',
'- item\n\n continued paragraph\n\n indented code in item\n\nend',
'> quote\n\n> second quote\n\nplain\n\n>lazy\ncontinuation',
'para\n\n indented code\n\n more code\n\nafter',
'```ts\nconst a = 1\n\nconst b = 2\n```\n\ntext\n\n~~~\n\n~~~\n\nend',
'````\n```\n\ninner\n```\n````\n\nafter fence\n\n```\nunterminated\n\nstill code',
'$$\na^2\n\n+ b^2\n$$\n\ntext with $x$ math\n\n\\[\ny\n\\]\n\nend',
'| a | b |\n| - | - |\n| 1 | 2 |\n\n| c |\n| - |\n| 3 |\n\nafter',
'Setext\n===\n\nAnother\n---\n\n***\n\n- - -\n\nend',
'see [ref] and [^note]\n\n[ref]: https://example.com\n\n[^note]: footnote body',
'- [ ] task\n- [x] done\n\n~~strike~~ and https://example.com autolink',
'<div>\n\nraw html block\n\n</div>\n\nafter html',
'\n\n\nleading blanks\n\n\n\nmany blanks\n\n',
' indented by three\n\n\tafter tab\n\n - nested\n\ntrailing',
'```a`b\n\ntext\n\n```\n\ncode?\n\n```\n\nend',
'- item\n\n ```\n x\n\n y\n ```\n\nafter\n\n> ```\n\nq',
'1. one\n\n $$\n a\n\n $$\n\nb\n\n10. ten\n\nc'
]
function canonicalHtml(element: HTMLElement): string {
const ids = new Map<string, string>()
const clone = element.cloneNode(true) as HTMLElement
for (const node of clone.querySelectorAll('*')) {
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'href']) {
const value = node.getAttribute(name)
if (value && /_r_[a-z0-9]+_|user-content-fn/u.test(value)) {
if (!ids.has(value)) ids.set(value, `id-${ids.size}`)
node.setAttribute(name, ids.get(value)!)
}
}
}
return clone.innerHTML
}
it('renders every streaming prefix identically to whole-document parsing', () => {
const segmented = render(<div />)
const reference = render(<div />)
let prefixes = 0
let multiSegment = 0
for (const content of segmentationCorpus) {
const step = Math.max(1, Math.floor(content.length / 120))
for (let end = 0; end <= content.length; end += step) {
const prefix = content.slice(0, end)
segmented.rerender(<div><MarkdownRenderer>{prefix}</MarkdownRenderer></div>)
reference.rerender(<div><UnsegmentedMarkdownRenderer>{prefix}</UnsegmentedMarkdownRenderer></div>)
expect(canonicalHtml(segmented.container), JSON.stringify(prefix))
.toBe(canonicalHtml(reference.container))
prefixes++
if (splitMarkdownSegments(normalizeLatexDelimiters(prefix)).length > 1) multiSegment++
}
}
expect(prefixes).toBeGreaterThan(1500)
expect(multiSegment).toBeGreaterThan(500)
}, 15000)
it('keeps completed segments intact and falls back for document-scoped syntax', () => {
// List markers never start a segment: the list may continue or turn loose.
expect(splitMarkdownSegments('a\n\nb\n\n- c\n\n- d\n\ne')).toEqual(['a\n\n', 'b\n\n- c\n\n- d\n\n', 'e'])
expect(splitMarkdownSegments('```\n\nx\n\n```\n\ny')).toEqual(['```\n\nx\n\n```\n\n', 'y'])
expect(splitMarkdownSegments('a\n\n[x]: https://example.com')).toHaveLength(1)
expect(splitMarkdownSegments('a\n\n<div>\n\nb\n\n</div>')).toHaveLength(1)
expect(splitMarkdownSegments('a\r\n\r\nb')).toHaveLength(1)
})
it('re-parses only the growing tail when completed segments are unchanged', () => {
const base = Array.from({ length: 30 }, (_, i) => `段落 ${i} **粗体** $x_${i}$`).join('\n\n')
const { rerender, container } = render(<MarkdownRenderer>{base}</MarkdownRenderer>)
const firstParagraph = container.querySelector('p')!
rerender(<MarkdownRenderer>{`${base} 追加`}</MarkdownRenderer>)
rerender(<MarkdownRenderer>{`${base} 追加\n\n新段落`}</MarkdownRenderer>)
// Untouched blocks keep their DOM nodes: they were not re-rendered.
expect(container.querySelector('p')).toBe(firstParagraph)
expect(container.querySelectorAll('p')).toHaveLength(31)
expect(container.querySelectorAll('.katex')).toHaveLength(30)
})
})
+163 -24
View File
@@ -1,5 +1,6 @@
import {
Children,
Fragment,
isValidElement,
lazy,
memo,
@@ -254,10 +255,154 @@ function standaloneHtmlSource(content: string): string | undefined {
return undefined
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
// Link reference and footnote definitions resolve across the whole document,
// even from inside lists or block quotes, so their presence disables splitting.
const documentScopedMarkdown = /\]:|\[\^/u
// Constructs that may legally span blank lines in ways a line scanner cannot
// track: raw HTML blocks and `$$` lines carrying other text.
const unsafeSegmentLine = /^ {0,3}(?:<|\$\$.*\S)/u
// CommonMark: a backtick fence's info string cannot contain backticks.
const segmentFenceOpening = /^ {0,3}(?:(`{3,})(?!.*`)|(~{3,}))/u
const segmentMathFence = /^ {0,3}\$\$\s*$/u
// A block after a blank line belongs to the previous top-level block when it
// is indented (list item or indented code continuation), a list marker (the
// list may continue and its looseness may change), or a block quote marker.
const continuationLine = /^(?:\s|[-*+](?:\s|$)|\d{1,9}[.)](?:\s|$)|>)/u
/**
* Splits Markdown at top-level boundaries where parsing each segment
* independently produces exactly the same blocks as parsing the whole text:
* a blank line, outside fenced code and math, followed by a line that starts
* a new top-level block. Returns a single segment whenever any construct
* could make that unsafe.
*/
export function splitMarkdownSegments(content: string): string[] {
if (documentScopedMarkdown.test(content) || content.includes('\r')) {
return [content]
}
const segments: string[] = []
let fence: { character: '`' | '~'; length: number } | undefined
let inMath = false
let segmentStart = 0
let segmentHasBlock = false
let previousBlank = false
let lineStart = 0
while (lineStart <= content.length) {
const newline = content.indexOf('\n', lineStart)
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd)
if (fence) {
const closing = new RegExp(
`^ {0,3}${fence.character}{${fence.length},}\\s*$`,
'u'
)
if (closing.test(line)) {
fence = undefined
}
previousBlank = false
} else if (inMath) {
if (segmentMathFence.test(line)) {
inMath = false
}
previousBlank = false
} else if (line.trim() === '') {
previousBlank = true
} else {
if (unsafeSegmentLine.test(line)) {
return [content]
}
// Leading blank lines render nothing; a segment of them would add an
// extra separator, so split only after the segment has a block.
if (
previousBlank &&
segmentHasBlock &&
!continuationLine.test(line)
) {
segments.push(content.slice(segmentStart, lineStart))
segmentStart = lineStart
}
segmentHasBlock = true
const opening = segmentFenceOpening.exec(line)
if (opening) {
const marker = (opening[1] ?? opening[2])!
fence = { character: marker[0] as '`' | '~', length: marker.length }
} else if (/^ {0,3}`{3,}/u.test(line)) {
// Backtick run with a backtick in its info string: inline code that
// the line scanner cannot model, so keep the document whole.
return [content]
} else if (segmentMathFence.test(line)) {
inMath = true
}
previousBlank = false
}
if (newline === -1) {
break
}
lineStart = newline + 1
}
segments.push(content.slice(segmentStart))
return segments
}
const markdownRehypePlugins: NonNullable<
React.ComponentProps<typeof ReactMarkdown>['rehypePlugins']
> = [
[
rehypeKatex,
{
output: 'htmlAndMathml',
strict: 'warn',
trust: false
}
]
]
const markdownRemarkPlugins: NonNullable<
React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']
> = [remarkGfm, [remarkMath, { singleDollarTextMath: true }]]
const MarkdownSegment = memo(function MarkdownSegment({
components,
content
}: {
components: Components
content: string
}): React.JSX.Element {
return (
<ReactMarkdown
components={components}
rehypePlugins={markdownRehypePlugins}
remarkPlugins={markdownRemarkPlugins}
skipHtml
>
{content}
</ReactMarkdown>
)
})
export const MarkdownRenderer = memo(function MarkdownRenderer(
props: MarkdownRendererProps
): React.JSX.Element {
return <MarkdownDocument {...props} segmented />
})
/** Whole-document reference rendering; used to verify segmentation. */
export const UnsegmentedMarkdownRenderer = memo(
function UnsegmentedMarkdownRenderer(
props: MarkdownRendererProps
): React.JSX.Element {
return <MarkdownDocument {...props} segmented={false} />
}
)
function MarkdownDocument({
children,
renderHtml = false
}: MarkdownRendererProps): React.JSX.Element {
renderHtml = false,
segmented
}: MarkdownRendererProps & { segmented: boolean }): React.JSX.Element {
const { t } = useTranslation('app')
const normalizedContent = normalizeLatexDelimiters(
unwrapMarkdownFence(children)
@@ -283,26 +428,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
)
}
// Completed segments keep identical strings, so streaming updates re-parse
// only the growing tail. The "\n" matches the text node ReactMarkdown
// places between top-level blocks, keeping the DOM identical.
const segments = segmented
? splitMarkdownSegments(normalizedContent)
: [normalizedContent]
return (
<ReactMarkdown
components={components}
rehypePlugins={[
[
rehypeKatex,
{
output: 'htmlAndMathml',
strict: 'warn',
trust: false
}
]
]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: true }]
]}
skipHtml
>
{normalizedContent}
</ReactMarkdown>
<>
{segments.map((segment, index) => (
<Fragment key={index}>
{index > 0 && '\n'}
<MarkdownSegment components={components} content={segment} />
</Fragment>
))}
</>
)
})
}
@@ -148,6 +148,22 @@ it('preserves the Obsidian draft after failures and folder-picker cancellation',
})
})
it('lists the image generation and save tools on the managed image card', async () => {
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
render(<McpSettingsSection onOpenImageModelSettings={vi.fn()} />)
await waitFor(() => expect(getSnapshot).toHaveBeenCalledOnce())
const name = i18n.t('integrations:conversationImages.title')
const toggle = screen.getByRole('button', { name: i18n.t('integrations:mcp.builtin.expandServer', { name }) })
expect(toggle).toHaveTextContent(i18n.t('integrations:mcp.builtin.toolCount', { count: 2 }))
expect(screen.queryByText('save_image')).not.toBeInTheDocument()
fireEvent.click(toggle)
const tools = screen.getByRole('region', { name: i18n.t('integrations:mcp.builtin.toolsAriaLabel', { name }) })
expect(tools).toHaveTextContent('generate_image')
expect(tools).toHaveTextContent('save_image')
expect(tools).toHaveTextContent(i18n.t('integrations:conversationImages.tools.save_image'))
})
it('removes the redundant MCP title and places the custom action in content', async () => {
const getSnapshot = vi.fn().mockResolvedValue({ mcpServers: [], skills: [] })
vi.stubGlobal('goodbuddy', { capabilities: { getSnapshot } })
+66 -2
View File
@@ -16,7 +16,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import {
builtinModelToolGroups,
builtinModelTools
} from '../../shared/builtin-model-tools'
import type {
BuiltinMcpServerId,
CapabilityDiagnosticReport,
@@ -40,6 +43,8 @@ const configurableMcpTargets: RuntimeTarget[] = [
'continue',
'deepseek-harness'
]
const imageTools = builtinModelTools.filter((tool) => tool.group === 'image')
const imageExpansionId = 'builtin:image-generation'
type McpSettingsTab =
| 'builtin'
| 'custom'
@@ -647,6 +652,65 @@ export function McpSettingsSection({
>
{t('conversationImages.openSettings')}
</button>
<button
aria-controls="mcp-server-tools-image-generation"
aria-expanded={expandedItemIds.has(imageExpansionId)}
aria-label={t(
expandedItemIds.has(imageExpansionId)
? 'mcp.builtin.collapseServer'
: 'mcp.builtin.expandServer',
{ name: t('conversationImages.title') }
)}
className="secondary-button builtin-mcp-card__details"
onClick={() => toggleItem(imageExpansionId)}
type="button"
>
{t('mcp.builtin.toolCount', { count: imageTools.length })}
<ChevronDown
aria-hidden="true"
className={
expandedItemIds.has(imageExpansionId)
? 'mcp-server-card__chevron mcp-server-card__chevron--expanded'
: 'mcp-server-card__chevron'
}
size={15}
/>
</button>
{expandedItemIds.has(imageExpansionId) && (
<div
className="builtin-mcp-card__tools"
id="mcp-server-tools-image-generation"
>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: t('conversationImages.title')
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>{t('mcp.builtin.tools')}</strong>
<small>
{t('mcp.profiles.count', { count: imageTools.length })}
</small>
</div>
<ul>
{imageTools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
<span className="builtin-tool-badge">
{tool.access === 'write'
? t('mcp.builtin.write')
: t('mcp.builtin.readOnly')}
</span>
</div>
<p>{t(`conversationImages.tools.${tool.name}`)}</p>
</li>
))}
</ul>
</section>
</div>
)}
</article>
{builtinMcpServers.map((server) => {
const expansionId = `builtin:${server.id}`
@@ -654,7 +718,7 @@ export function McpSettingsSection({
const panelId = `mcp-server-tools-${server.id}`
const state = builtinMcpStates.get(server.id) ?? {
id: server.id,
enabled: server.id !== 'builtin-browser' && server.id !== 'obsidian',
enabled: server.id !== 'obsidian',
assignments:
[...server.supportedAssignments] as CapabilityAssignments
}
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import type { AppNotificationInput } from './notifications'
export function ModelDownloadSourceSettings({
open, onOpen, onClose, onNotify, onSourceChanged
}: {
open: boolean
onOpen: () => void
onClose: () => void
onNotify?: (notification: AppNotificationInput) => void
onSourceChanged: (source: ModelDownloadSource) => void
}): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const [source, setSource] = useState<ModelDownloadSource>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string>()
const [attempt, setAttempt] = useState(0)
const trigger = useRef<HTMLButtonElement>(null)
const returnFocus = useCallback(() => trigger.current, [])
const receiveSource = useCallback((value: ModelDownloadSource) => {
setSource(value)
onSourceChanged(value)
}, [onSourceChanged])
useEffect(() => {
let active = true
const updates = window.goodbuddy.updates
// A settings event is newer than the initial read still in flight.
let receivedChange = false
const unsubscribe = updates?.onSettingsChanged((settings) => {
receivedChange = true
receiveSource(settings.modelDownloadSource)
setError(undefined)
setLoading(false)
})
void (async () => {
try {
if (!updates) throw new Error('unavailable')
const settings = await updates.getSettings()
if (active && !receivedChange) {
receiveSource(settings.modelDownloadSource)
}
} catch {
if (active && !receivedChange) {
setError(t('platformFeatures.modelDownloadSource.readFailed'))
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
unsubscribe?.()
}
}, [attempt, t, receiveSource])
const retry = (): void => {
setLoading(true)
setError(undefined)
setAttempt((value) => value + 1)
}
return (
<div className="model-download-source-control">
<small role={loading ? 'status' : undefined}>
{source
? t('platformFeatures.modelDownloadSource.current', {
source: t(`modelDownloadSources.${source}`)
})
: loading
? t('platformFeatures.loading')
: t('platformFeatures.modelDownloadSource.readFailed')}
</small>
<button className="secondary-button" onClick={onOpen} ref={trigger} type="button">
{t('platformFeatures.modelDownloadSource.title')}
</button>
{open && (
<ModelDownloadSourceDialog
source={source}
loading={loading}
loadError={error}
onRetry={retry}
onClose={onClose}
onSaved={receiveSource}
onNotify={onNotify}
returnFocus={returnFocus}
/>
)}
</div>
)
}
function ModelDownloadSourceDialog({
source, loading, loadError, onRetry, onClose, onSaved, onNotify, returnFocus
}: {
source?: ModelDownloadSource
loading: boolean
loadError?: string
onRetry: () => void
onClose: () => void
onSaved: (source: ModelDownloadSource) => void
onNotify?: (notification: AppNotificationInput) => void
returnFocus: () => HTMLElement | null
}): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings')
const [draft, setDraft] = useState(source)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => activateModalFocus(() => closeButton.current, returnFocus), [returnFocus])
const selection = draft ?? source
const close = (): void => {
if (!saving) onClose()
}
const confirm = async (): Promise<void> => {
if (saving || loading || !selection || loadError) return
if (selection === source) {
onClose()
return
}
setSaving(true)
setError(undefined)
try {
const updates = window.goodbuddy.updates
if (!updates) throw new Error('unavailable')
const settings = await updates.updateSettings({ modelDownloadSource: selection })
onSaved(settings.modelDownloadSource)
onNotify?.({
tone: 'success',
dedupeKey: 'model-download-source',
message: t('platformFeatures.modelDownloadSource.notification', {
source: t(`modelDownloadSources.${settings.modelDownloadSource}`)
})
})
onClose()
} catch {
setError(t('platformFeatures.errors.saveModelDownloadSourceFailed'))
} finally {
setSaving(false)
}
}
return createPortal(
<div className="custom-task-dialog" onMouseDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault()
close()
}
}}>
<section
className="custom-task-dialog__surface"
role="dialog"
aria-modal="true"
tabIndex={-1}
aria-labelledby="model-download-source-title"
aria-describedby="model-download-source-description"
aria-busy={saving || loading}
onKeyDown={(event) => {
event.stopPropagation()
trapTabFocus(event, event.currentTarget)
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}}
>
<header className="custom-task-dialog__header">
<h2 id="model-download-source-title">{t('platformFeatures.modelDownloadSource.title')}</h2>
<button
className="icon-button"
type="button"
ref={closeButton}
disabled={saving}
onClick={close}
aria-label={t('platformFeatures.modelDownloadSource.close')}
title={t('platformFeatures.modelDownloadSource.close')}
>
<X size={18} aria-hidden="true" />
</button>
</header>
<div className="custom-task-dialog__content">
<p id="model-download-source-description">{t('platformFeatures.modelDownloadSource.description')}</p>
{loading ? (
<p role="status">{t('platformFeatures.loading')}</p>
) : loadError ? (
<div>
<p role="alert">{loadError}</p>
<button className="secondary-button" type="button" onClick={onRetry}>
{t('platformFeatures.modelDownloadSource.retry')}
</button>
</div>
) : (
<fieldset
className="model-download-source"
disabled={saving}
aria-describedby={error ? 'model-download-source-error' : undefined}
>
<legend className="sr-only">{t('platformFeatures.modelDownloadSource.title')}</legend>
{(['modelscope', 'hugging-face'] as const).map((value) => (
<label
key={value}
className={`model-download-source__option${selection === value ? ' model-download-source__option--selected' : ''}`}
>
<input
type="radio"
name="model-download-source"
value={value}
checked={selection === value}
onChange={() => {
setDraft(value)
setError(undefined)
}}
/>
<span>
<strong>{t(`modelDownloadSources.${value}`)}</strong>
<small>{t(`platformFeatures.modelDownloadSource.options.${value}`)}</small>
</span>
</label>
))}
</fieldset>
)}
{error && <p className="settings-warning" id="model-download-source-error" role="alert">{error}</p>}
</div>
<footer className="custom-task-dialog__actions">
<button className="secondary-button" type="button" disabled={saving} onClick={close}>
{settingsT('actions.cancel')}
</button>
<button
className="primary-button"
type="button"
disabled={saving || loading || !selection || Boolean(loadError)}
onClick={() => void confirm()}
>
{saving ? settingsT('actions.saving') : t('platformFeatures.modelDownloadSource.confirm')}
</button>
</footer>
</section>
</div>,
document.body
)
}
@@ -16,8 +16,7 @@ import {
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
ApplicationSettings,
ModelDownloadSource
ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
AgentPackageDownloadProgress,
@@ -94,7 +93,6 @@ export function PlatformFeaturesSettingsSection({
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
@@ -426,45 +424,6 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeModelDownloadSource = async (
modelDownloadSource: ModelDownloadSource
): Promise<void> => {
const updates = window.goodbuddy.updates
if (
!updates ||
!settings ||
settings.modelDownloadSource === modelDownloadSource
) {
return
}
setSaving(true)
setSourceError(undefined)
try {
const nextSettings = await updates.updateSettings({
modelDownloadSource
})
setSettings(nextSettings)
onNotify?.({
tone: 'success',
message: t(
'platformFeatures.modelDownloadSource.notification',
{
source: t(
`modelDownloadSources.${nextSettings.modelDownloadSource}`
)
}
),
dedupeKey: 'model-download-source'
})
} catch {
setSourceError(
t('platformFeatures.errors.saveModelDownloadSourceFailed')
)
} finally {
setSaving(false)
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
@@ -780,69 +739,6 @@ export function PlatformFeaturesSettingsSection({
</button>
</div>
</label>
{settings ? (
<>
<div className="capability-card__header">
<span className="inline-help-label">
<strong>
{t('platformFeatures.modelDownloadSource.title')}
</strong>
<InlineHelp label={t('platformFeatures.modelDownloadSource.title')}>
{t(
'platformFeatures.modelDownloadSource.description'
)}
</InlineHelp>
</span>
</div>
<fieldset className="model-download-source">
<legend className="sr-only">
{t('platformFeatures.modelDownloadSource.title')}
</legend>
{(
['modelscope', 'hugging-face'] as const
).map((source) => (
<label
className={
source === settings.modelDownloadSource
? 'model-download-source__option model-download-source__option--selected'
: 'model-download-source__option'
}
key={source}
>
<input
checked={source === settings.modelDownloadSource}
disabled={saving}
name="model-download-source"
onChange={() =>
void changeModelDownloadSource(source)
}
type="radio"
value={source}
/>
<span>
<strong>{t(`modelDownloadSources.${source}`)}</strong>
<small>
{t(
`platformFeatures.modelDownloadSource.options.${source}`
)}
</small>
</span>
</label>
))}
</fieldset>
{sourceError && (
<p className="settings-warning" role="alert">
{sourceError}
</p>
)}
</>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
</article>
</section>
)}
+5 -3
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle } from 'lucide-react'
import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal, flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { ConversationActivity } from './conversation-activity'
@@ -241,7 +241,9 @@ function ActivityMenu({
)
}
export function ProjectActivity({
// Memoized: App re-renders on every streaming delta and keystroke, while the
// activity summary it passes keeps its identity unless an activity changes.
export const ProjectActivity = memo(function ProjectActivity({
activities,
projects,
visible = true,
@@ -292,4 +294,4 @@ export function ProjectActivity({
)}
</>
)
}
})
@@ -1,9 +1,38 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { QueuedAttachmentsDialog } from './QueuedAttachmentsDialog'
import { DocumentConversationContext } from './DocumentConversationContext'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it.each([true, false])('previews queued images without parsing (full image available: %s)', async (fullImage) => {
const thumbnailUrl = 'data:image/png;base64,thumbnail'
const contentUrl = fullImage ? 'data:image/png;base64,full' : undefined
const openImage = vi.fn()
const getSnapshot = vi.fn()
const reparseDraft = vi.fn()
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
vi.stubGlobal('goodbuddy', {
conversationQueue: { getAttachments: vi.fn().mockResolvedValue([
{ id: 'image', resourceId: 'image', name: 'screenshot.png', kind: 'image', sendMode: 'image', thumbnailUrl, contentUrl }
]) },
documentParsing: { getSnapshot }, context: { reparseDraft }
})
render(<DocumentConversationContext.Provider value={{ openImage, create: vi.fn(), navigate: vi.fn(), notify: vi.fn() }}>
<QueuedAttachmentsDialog itemId="queued" onClose={vi.fn()} />
</DocumentConversationContext.Provider>)
expect(await screen.findByRole('img', { name: 'screenshot.png' })).toHaveAttribute('src', thumbnailUrl)
const trigger = screen.getByRole('button', { name: '查看图片 screenshot.png' })
fireEvent.click(trigger)
expect(openImage).toHaveBeenCalledWith(contentUrl ?? thumbnailUrl, 'screenshot.png', trigger)
fireEvent.click(screen.getByRole('button', { name: '更多附件操作:screenshot.png' }))
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
expect(screen.getByRole('menuitem')).toHaveTextContent('打开原文件')
expect(screen.queryByRole('button', { name: /查看解析结果/ })).not.toBeInTheDocument()
expect(getSnapshot).not.toHaveBeenCalled()
expect(reparseDraft).not.toHaveBeenCalled()
})
it('opens snapshot guidance without hiding empty state or closing the dialog on Escape', async () => {
const onClose = vi.fn()
vi.stubGlobal('goodbuddy', { conversationQueue: { getAttachments: vi.fn().mockResolvedValue([]) } })
+7 -1
View File
@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { ContextAttachment } from '../../shared/contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { AttachmentResultButton } from './AttachmentResultButton'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { InlineHelp } from './InlineHelp'
import { DocumentConversationContext } from './DocumentConversationContext'
export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; onClose: () => void }): React.JSX.Element {
const close = useRef<HTMLButtonElement>(null)
const conversationContext = useContext(DocumentConversationContext)
const [items, setItems] = useState<ContextAttachment[]>()
const [error, setError] = useState('')
useEffect(() => activateModalFocus(() => close.current), [])
@@ -29,6 +31,10 @@ export function QueuedAttachmentsDialog({ itemId, onClose }: { itemId: string; o
{items?.length === 0 && <p>此输入没有附件。</p>}
<div className="document-result-panel">{items?.map((item) => <article key={item.id}>
<h3>{item.name}</h3>
{item.kind === 'image' && (item.thumbnailUrl || item.contentUrl) && <div className="document-result-image"><button
type="button" className="message-image-button" aria-label={`查看图片 ${item.name}`}
onClick={(event) => conversationContext?.openImage(item.contentUrl ?? item.thumbnailUrl!, item.name, event.currentTarget)}
><img src={item.thumbnailUrl ?? item.contentUrl} alt={item.name} loading="lazy" /></button></div>}
<div className="attachment-metadata"><AttachmentStatus attachment={item} /></div>
<div className="attachment-actions">
{item.resultId && <AttachmentResultButton resultId={item.resultId} name={item.name} readOnly />}
+3 -3
View File
@@ -1,5 +1,5 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { memo, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import { isCancelledMessage } from './message-terminal-status'
@@ -13,7 +13,7 @@ const statusIcons = {
cancelled: XCircle
}
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
export const RuntimeChecklistStrip = memo(function RuntimeChecklistStrip({ messages, activeMessageId }: {
messages: readonly Message[]
activeMessageId?: string
}): React.JSX.Element | null {
@@ -26,7 +26,7 @@ export function RuntimeChecklistStrip({ messages, activeMessageId }: {
: messages.at(-1)
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
return <Checklist key={message.id} message={message} />
}
})
function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')
+239 -27
View File
@@ -40,6 +40,7 @@ import type {
EmbeddingSettingsSnapshot
} from '../../shared/embedding-contracts'
import type { SpeechModelSnapshot } from '../../shared/speech-model-contracts'
import type { DocumentParsingSnapshot } from '../../shared/document-parsing-contracts'
import { builtinMcpServers } from '../../shared/builtin-mcp-servers'
import { builtinModelToolGroups } from '../../shared/builtin-model-tools'
import {
@@ -520,10 +521,11 @@ const removeEmbeddingModel = vi.fn(
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -814,10 +816,11 @@ describe('SettingsPanel runtime files', () => {
applicationSettings = {
checkUpdatesOnStartup: true,
desktopNotificationsEnabled: true,
transparentFrostedEffectEnabled: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true,
applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, deviceSharingEnabled: false,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
@@ -1163,7 +1166,7 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '上下文控制' })
).toHaveTextContent('直连模型的历史压缩与原文保留')
).toHaveTextContent(/^上下文控制$/)
const enabled = screen.getByRole('switch', {
name: '自动压缩较早的对话'
})
@@ -1401,7 +1404,7 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(
screen.getByRole('tab', { name: /Platform Features/i })
)
expect(screen.getByText('Workspace & downloads')).toBeInTheDocument()
expect(screen.getByText('Workspace')).toBeInTheDocument()
expect(
screen.getByLabelText('Default workspace folder')
).toBeInTheDocument()
@@ -1505,7 +1508,7 @@ describe('SettingsPanel runtime files', () => {
expect(applicationSettings).toEqual(storedSettings)
})
it('switches the global model download source from General settings', async () => {
it('confirms the model download source beside the model types, below save actions', async () => {
const onNotify = vi.fn()
render(
<SettingsPanel
@@ -1518,8 +1521,15 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const modelScope = await screen.findByRole('radio', {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const trigger = screen.getByRole('button', { name: '模型下载源' })
expect(trigger.closest('.model-type-navigation')).toContainElement(screen.getByRole('group', { name: '模型类型' }))
expect(trigger.closest('.settings-category-header__navigation')?.previousElementSibling).toHaveClass('settings-category-header__actions')
expect(await screen.findByText('当前来源:ModelScope')).toBeInTheDocument()
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
const modelScope = within(dialog).getByRole('radio', {
name: /ModelScope/u
})
const huggingFace = screen.getByRole('radio', {
@@ -1532,21 +1542,58 @@ describe('SettingsPanel runtime files', () => {
).not.toBeInTheDocument()
fireEvent.click(huggingFace)
expect(updateApplicationSettings).not.toHaveBeenCalled()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
modelDownloadSource: 'hugging-face'
})
)
expect(huggingFace).toBeChecked()
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(trigger).toHaveFocus()
})
expect(onNotify).toHaveBeenCalledWith({
tone: 'success',
message: '模型下载源已切换为 Hugging Face。',
dedupeKey: 'model-download-source'
})
expect(
screen.getByRole('tab', { name: '远程项目' })
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
expect(screen.queryByRole('radio', { name: /ModelScope/u })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '模型下载源' })).not.toBeInTheDocument()
})
it.each([
['zh-CN', '外观', '透明磨砂特效', '应用于顶栏和展开的运行待办卡片,保存后立即生效。'],
['en-US', 'Appearance', 'Frosted glass effect', 'Applies to the top bar and expanded runtime checklist. Takes effect as soon as it is saved.']
] as const)('exposes the frosted glass Switch under Appearance in %s', async (locale, category, label, description) => {
await changeUiLocale(locale)
const onChange = vi.fn(async () => true)
const props = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn(),
onTransparentFrostedEffectEnabledChange: onChange
}
const { rerender } = render(<SettingsPanel {...props} />)
fireEvent.click(screen.getByRole('tab', { name: category }))
const toggle = screen.getByRole('switch', { name: label })
expect(toggle).toBeChecked()
expect(toggle).toHaveAccessibleDescription(description)
expect(toggle.closest('label')).toHaveClass('toggle-row')
fireEvent.click(toggle)
expect(onChange).toHaveBeenCalledExactlyOnceWith(false)
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled={false} applicationSettingsPending />)
expect(toggle).not.toBeChecked()
expect(toggle).toBeDisabled()
rerender(<SettingsPanel {...props} transparentFrostedEffectEnabled={false} />)
fireEvent.click(toggle)
expect(onChange).toHaveBeenLastCalledWith(true)
})
it.each([
@@ -1598,9 +1645,9 @@ describe('SettingsPanel runtime files', () => {
const conversation = await screen.findByRole('article', { name: '会话与通知' })
expect(within(conversation).getByRole('switch', { name: '桌面通知' })).not.toBeChecked()
expect(within(conversation).getByRole('switch', { name: '在会话中渲染 HTML' })).toBeChecked()
const workspace = screen.getByRole('article', { name: '工作目录与下载' })
const workspace = screen.getByRole('article', { name: '工作目录' })
expect(within(workspace).getByRole('textbox')).toHaveValue(runtimeSettings.workspacePath)
expect(within(workspace).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
expect(within(workspace).queryByRole('radio')).not.toBeInTheDocument()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
@@ -1676,7 +1723,7 @@ describe('SettingsPanel runtime files', () => {
expect(toggle).not.toBeChecked()
})
it('does not guess a model download source when settings fail to load', async () => {
it('does not guess platform settings when they fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
)
@@ -1712,7 +1759,7 @@ describe('SettingsPanel runtime files', () => {
)
})
it('keeps the confirmed model download source when saving fails', async () => {
it('keeps the source draft after a failed confirmation and allows retry', async () => {
updateApplicationSettings.mockRejectedValueOnce(
new Error('save failed')
)
@@ -1726,7 +1773,9 @@ describe('SettingsPanel runtime files', () => {
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const modelScope = await screen.findByRole('radio', {
name: /ModelScope/u
})
@@ -1734,12 +1783,164 @@ describe('SettingsPanel runtime files', () => {
name: /Hugging Face/u
})
fireEvent.click(huggingFace)
fireEvent.click(screen.getByRole('button', { name: '确认' }))
expect(
await screen.findByText('保存模型下载源失败,请重试')
).toBeInTheDocument()
expect(modelScope).toBeChecked()
expect(huggingFace).not.toBeChecked()
expect(modelScope).not.toBeChecked()
expect(huggingFace).toBeChecked()
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
expect(updateApplicationSettings).toHaveBeenCalledTimes(2)
})
})
it.each(['cancel', 'escape', 'close', 'backdrop'])('discards source selection on %s without saving', async (method) => {
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
const trigger = screen.getByRole('button', { name: '模型下载源' })
trigger.focus()
fireEvent.click(trigger)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
if (method === 'escape') fireEvent.keyDown(dialog, { key: 'Escape' })
else if (method === 'backdrop') fireEvent.mouseDown(dialog.parentElement!)
else fireEvent.click(within(dialog).getByRole('button', { name: method === 'cancel' ? '取消' : '关闭模型下载源' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
fireEvent.click(trigger)
expect(screen.getByRole('radio', { name: /ModelScope/u })).toBeChecked()
})
it('locks the source dialog during confirmation and keeps the confirmed label until saved', async () => {
let resolveSave!: (value: ApplicationSettings) => void
updateApplicationSettings.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
const onClose = vi.fn()
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={onClose} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('当前来源:ModelScope')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(within(dialog).getByRole('button', { name: '保存中…' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '取消' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: '关闭模型下载源' })).toBeDisabled()
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeDisabled()
fireEvent.keyDown(dialog, { key: 'Escape' })
fireEvent.mouseDown(dialog.parentElement!)
expect(dialog).toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
expect(updateApplicationSettings).toHaveBeenCalledTimes(1)
expect(screen.getByText('当前来源:ModelScope')).toBeInTheDocument()
await act(async () => resolveSave({ ...applicationSettings, modelDownloadSource: 'hugging-face' }))
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
expect(screen.getByText('当前来源:Hugging Face')).toBeInTheDocument()
})
it('retries loading the source without guessing a selected radio', async () => {
getApplicationSettings.mockRejectedValueOnce(new Error('read failed'))
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByText('读取模型下载源失败,请重试')
fireEvent.click(screen.getByRole('button', { name: '模型下载源' }))
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(within(dialog).queryByRole('radio')).not.toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeDisabled()
fireEvent.click(within(dialog).getByRole('button', { name: '重试' }))
await waitFor(() => {
expect(within(dialog).getByRole('radio', { name: /ModelScope/u })).toBeChecked()
expect(within(dialog).getByRole('button', { name: '确认' })).toBeEnabled()
})
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it.each(['embedding', 'speech'])('opens the shared source dialog from %s and updates download availability after confirm', async (kind) => {
const downloadAvailability = [
{ source: 'modelscope' as const, available: false, unavailableReason: '当前来源缺少完整文件' },
{ source: 'hugging-face' as const, available: true, totalBytes: 1 }
]
getEmbeddingSnapshot.mockResolvedValueOnce({
...embeddingSnapshot,
models: { ...embeddingSnapshot.models, catalog: [{ ...embeddingCatalogEntry, downloadAvailability }] }
})
speechModelSnapshot = {
...createSpeechModelSnapshot(null),
installed: [],
catalog: [{ ...speechCatalog[0]!, downloadAvailability }]
}
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: kind === 'embedding' ? '向量模型' : '语音输入' }))
if (kind === 'embedding') {
fireEvent.click(await screen.findByRole('button', { name: '编辑向量模型连接 GoodBuddy 内置向量模型' }))
}
await screen.findByText('当前来源不可下载')
const entry = screen.getAllByRole('button', { name: '模型下载源' }).find((button) => !button.closest('.model-type-navigation'))!
entry.focus()
fireEvent.click(entry)
const dialog = screen.getByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
fireEvent.click(within(dialog).getByRole('radio', { name: /Hugging Face/u }))
fireEvent.click(within(dialog).getByRole('button', { name: '确认' }))
const modelName = kind === 'embedding' ? embeddingCatalogEntry.displayName : speechCatalog[0]!.displayName
const download = await screen.findByRole('button', { name: `下载 ${modelName}` })
expect(screen.queryByText('当前来源不可下载')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
fireEvent.click(download)
await waitFor(() => {
if (kind === 'embedding') expect(installEmbeddingModel).toHaveBeenCalledWith(embeddingCatalogEntry.id, 'hugging-face')
else expect(window.goodbuddy.speechModels!.install).toHaveBeenCalledWith(speechCatalog[0]!.id, 'hugging-face')
})
})
it.each([false, true])('opens the source dialog on the model page from OCR, respecting dirty draft = %s', async (dirty) => {
const snapshot: DocumentParsingSnapshot = {
settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
status: { nativeParsingAvailable: true, conversionAvailable: false, localOcr: {
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: '模型尚未安装'
} },
ocrModels: {
rootDirectory: 'C:\\models\\ocr', selectedDownloadSource: 'modelscope', installed: [], operations: [],
catalog: [{
id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', description: 'OCR', languages: ['中文'],
runtime: 'onnxruntime-web-wasm', quality: 'basic', speed: 'fast', recommended: true,
license: { name: 'Apache-2.0', notice: 'Apache-2.0', url: 'https://example.com/license' }, files: [],
downloadAvailability: [{ source: 'modelscope', available: false, unavailableReason: '当前来源缺少完整文件' }]
}]
}
}
Object.defineProperty(window.goodbuddy, 'documentParsing', {
configurable: true,
value: { getSnapshot: vi.fn(async () => snapshot), getOcrModelProgress: vi.fn(async () => ({ operations: [] })) }
})
render(<SettingsPanel {...heartbeatSettingsProps} open onClearLocalData={vi.fn(async () => {})} onClose={vi.fn()} onSaved={vi.fn()} />)
fireEvent.click(screen.getByRole('tab', { name: '文档解析' }))
const entry = await screen.findByRole('button', { name: '模型下载源' })
if (dirty) fireEvent.change(screen.getByRole('combobox', { name: '聊天与成果文件' }), { target: { value: 'fast-text' } })
entry.focus()
fireEvent.click(entry)
if (dirty) {
expect(screen.queryByRole('dialog', { name: '模型下载源' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
expect(screen.getByRole('combobox', { name: '聊天与成果文件' })).toHaveValue('fast-text')
fireEvent.click(entry)
fireEvent.click(screen.getByRole('button', { name: '放弃更改并切换' }))
}
const dialog = await screen.findByRole('dialog', { name: '模型下载源' })
expect(document.getElementById('settings-tab-model')).toHaveAttribute('aria-selected', 'true')
expect(await within(dialog).findByRole('radio', { name: /ModelScope/u })).toBeChecked()
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
expect(screen.getByRole('button', { name: '模型下载源' })).toHaveFocus()
expect(updateApplicationSettings).not.toHaveBeenCalled()
})
it('refreshes built-in Notes MCP after enabling Magic Notes', async () => {
@@ -1796,15 +1997,22 @@ describe('SettingsPanel runtime files', () => {
expect(content.parentElement).toBe(navigation.parentElement)
expect(content).toHaveClass('settings-panel__content')
expect(
screen.getByText('管理模型、Runtime、平台能力、消息通道与本地数据。')
).toBeInTheDocument()
screen.queryByText('管理模型、Runtime、平台能力、消息通道与本地数据。')
).not.toBeInTheDocument()
const runtimeTab = screen.getByRole('tab', {
name: 'Agent Runtime'
})
expect(runtimeTab).toHaveTextContent('Agent Runtime')
expect(runtimeTab).toHaveTextContent(
'配置 Agent Runtime、默认工作区与原生能力'
)
expect(runtimeTab).toHaveTextContent(/^Agent Runtime$/)
expect(
Array.from(navigation.querySelectorAll('.settings-tabs__group-label'), node => node.textContent)
).toEqual(['常规', '模型与执行', '功能与扩展', '系统'])
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
'settings-tab-ssh-hosts',
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-security', 'settings-tab-about'
])
})
it('omits the redundant close-only footer on passive settings pages', () => {
@@ -5834,10 +6042,14 @@ describe('SettingsPanel runtime files', () => {
screen.getAllByRole('button', {
name: /^(?:展开|收起)服务器 /u
})
).toHaveLength(builtinMcpServers.length)
for (const server of builtinMcpServers) {
).toHaveLength(builtinMcpServers.length + 1)
const serverNames = [
...builtinMcpServers.map(server => server.name),
'图片生成与编辑'
]
for (const serverName of serverNames) {
expect(screen.getByRole('button', {
name: name => name === `展开服务器 ${server.name}` || name === `收起服务器 ${server.name}`
name: name => name === `展开服务器 ${serverName}` || name === `收起服务器 ${serverName}`
})).toBeInTheDocument()
}
expect(
+98 -41
View File
@@ -10,6 +10,7 @@ import {
X
} from 'lucide-react'
import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
@@ -53,6 +54,8 @@ import { CapabilitiesAndToolsSettingsSection } from './CapabilitiesAndToolsSetti
import { ChannelSettingsSection } from './ChannelSettingsSection'
import { UpdateSettingsSection } from './UpdateSettingsSection'
import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSection'
import { ModelDownloadSourceSettings } from './ModelDownloadSourceSettings'
import type { ModelDownloadSource } from '../../shared/application-settings-contracts'
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
@@ -234,6 +237,12 @@ type SettingsPanelProps = {
onExpertsChanged?: (experts: AssistantExpert[]) => void
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
transparentFrostedEffectEnabled?: boolean
applicationSettingsPending?: boolean
applicationSettingsLocked?: boolean
applicationSettingsError?: string
onTransparentFrostedEffectEnabledChange?: (enabled: boolean) => Promise<boolean>
onRetryApplicationSettings?: () => void
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
@@ -647,6 +656,12 @@ export function SettingsPanel({
onClearLocalData,
onExpertsChanged = () => {},
appearanceTheme = 'system',
transparentFrostedEffectEnabled = true,
applicationSettingsPending = false,
applicationSettingsLocked = false,
applicationSettingsError,
onTransparentFrostedEffectEnabledChange,
onRetryApplicationSettings,
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
@@ -766,7 +781,7 @@ export function SettingsPanel({
const [testing, setTesting] = useState(false)
const [embeddingSnapshot, setEmbeddingSnapshot] =
useState<EmbeddingSettingsSnapshot>()
const [embeddingModels, setEmbeddingModels] =
const [embeddingModelSnapshot, setEmbeddingModels] =
useState<EmbeddingModelSnapshot>()
const [embeddingDiagnostic, setEmbeddingDiagnostic] =
useState<EmbeddingDiagnosticResult>()
@@ -795,6 +810,11 @@ export function SettingsPanel({
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('llm')
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
const embeddingModels = embeddingModelSnapshot && modelDownloadSource
? { ...embeddingModelSnapshot, selectedDownloadSource: modelDownloadSource }
: embeddingModelSnapshot
const [speechModelDraftId, setSpeechModelDraftId] = useState<
string | null | undefined
>()
@@ -815,7 +835,7 @@ export function SettingsPanel({
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId }
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
| { kind: 'external'; proceed: () => void }
>()
const runtimeCustomizationRef =
@@ -1040,7 +1060,8 @@ export function SettingsPanel({
const requestTabChange = (
category: SettingsCategoryId,
trigger?: HTMLElement
trigger?: HTMLElement,
openModelDownloadSource = false
): boolean => {
if (category === activeTab) {
return true
@@ -1051,7 +1072,7 @@ export function SettingsPanel({
(document.activeElement instanceof HTMLElement
? document.activeElement
: undefined)
setPendingLeave({ kind: 'navigate', category })
setPendingLeave({ kind: 'navigate', category, openModelDownloadSource })
return false
}
setPendingLeave(undefined)
@@ -1060,6 +1081,10 @@ export function SettingsPanel({
return true
}
const openModelDownloadSource = (): void => {
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
}
const visibleSettingsCategories = getSettingsCategoryList(
remoteProjectsEnabled
)
@@ -1315,6 +1340,10 @@ export function SettingsPanel({
setError(undefined)
if (leave?.kind === 'navigate') {
setActiveTab(leave.category)
if (leave.openModelDownloadSource) {
setModelDownloadSourceOpen(true)
return
}
requestAnimationFrame(() =>
document
.getElementById(`settings-tab-${leave.category}`)
@@ -2124,7 +2153,6 @@ export function SettingsPanel({
<X aria-hidden="true" size={19} />
</button>
}
description={t('center.description')}
headingId="settings-title"
title={t('center.title')}
/>
@@ -2142,34 +2170,35 @@ export function SettingsPanel({
className="settings-tabs"
role="tablist"
>
{visibleSettingsCategories.map((category) => (
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
`categories.${category.translationKey}.label`
{visibleSettingsCategories.map((category, index) => (
<Fragment key={category.id}>
{visibleSettingsCategories[index - 1]?.group !== category.group && (
<span className="settings-tabs__group-label" aria-hidden="true">
{t(`categoryGroups.${category.group}`)}
</span>
)}
aria-selected={activeTab === category.id}
id={`settings-tab-${category.id}`}
key={category.id}
onClick={(event) => {
requestTabChange(category.id, event.currentTarget)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
}
role="tab"
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
<small>
{t(
`categories.${category.translationKey}.navigationDescription`
<button
aria-controls={`settings-panel-${category.id}`}
aria-label={t(
`categories.${category.translationKey}.label`
)}
</small>
</button>
aria-selected={activeTab === category.id}
id={`settings-tab-${category.id}`}
onClick={(event) => {
requestTabChange(category.id, event.currentTarget)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, category.id)
}
role="tab"
tabIndex={activeTab === category.id ? 0 : -1}
type="button"
>
<strong>
{t(`categories.${category.translationKey}.label`)}
</strong>
</button>
</Fragment>
))}
</nav>
@@ -2272,7 +2301,7 @@ export function SettingsPanel({
/>
</div>
) : activeTab === 'model' ? (
<div className="model-type-navigation">
<div className="model-type-navigation model-connections-navigation">
<SegmentedControl
ariaLabel={t('model.typeAriaLabel')}
onChange={setModelType}
@@ -2284,6 +2313,13 @@ export function SettingsPanel({
]}
value={modelType}
/>
<ModelDownloadSourceSettings
open={modelDownloadSourceOpen}
onSourceChanged={setModelDownloadSource}
onOpen={openModelDownloadSource}
onClose={() => setModelDownloadSourceOpen(false)}
onNotify={onNotify}
/>
</div>
) : undefined
}
@@ -2339,6 +2375,32 @@ export function SettingsPanel({
)}
</div>
</div>
<div className="settings-section">
<label className="toggle-row">
<span>{t('appearance.frostedGlass.label')}</span>
<input
type="checkbox"
role="switch"
checked={transparentFrostedEffectEnabled}
disabled={applicationSettingsPending || applicationSettingsLocked || !onTransparentFrostedEffectEnabledChange}
aria-describedby="frosted-glass-description"
onChange={(event) => {
void onTransparentFrostedEffectEnabledChange?.(event.target.checked)
}}
/>
</label>
<p id="frosted-glass-description" className="settings-panel__description">
{t('appearance.frostedGlass.description')}
</p>
{applicationSettingsError && (
<div role="alert">
<p>{applicationSettingsError}</p>
<button className="secondary-button" disabled={applicationSettingsPending} onClick={onRetryApplicationSettings} type="button">
{t('appearance.frostedGlass.retry')}
</button>
</div>
)}
</div>
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<span className="inline-help-label">
@@ -3730,9 +3792,7 @@ export function SettingsPanel({
})
)
}}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
onSelectConnection={setEditingEmbeddingConnectionId}
onSetCurrent={setActiveEmbeddingConnectionId}
onTestConnection={(connectionId) => {
@@ -3878,10 +3938,9 @@ export function SettingsPanel({
)}
{modelType === 'speech' && (
<SpeechModelSettingsSection
downloadSource={modelDownloadSource}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
onSelectedModelIdChange={(modelId, changed) => {
setSpeechModelDraftId(modelId)
setSpeechModelSelectionDirty(changed)
@@ -4088,9 +4147,7 @@ export function SettingsPanel({
onBusyChange={setTesting}
onDirtyChange={setDocumentParsingDirty}
onNotify={onNotify}
onOpenModelDownloadSourceSettings={() =>
requestTabChange('platform-features')
}
onOpenModelDownloadSourceSettings={openModelDownloadSource}
/>
)}

Some files were not shown because too many files have changed in this diff Show More