Compare commits

...
22 Commits
Author SHA1 Message Date
mesalogo a662b16f5e fix: remove the conversation runtime override dot
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
Keep the Runtime icon and label clear after a conversation-specific selection. Preserve override details and the reset action inside the picker, with regression coverage and bilingual release notes.

Release note: 移除会话 Runtime 按钮图标与名称之间的圆点,保留菜单内的单独选择提示与恢复项目默认操作。
2026-09-29 22:50:43 +08:00
mesalogo 911eed5e67 test: wait for runtime readiness before question submission
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
Wait for the enabled send control rather than its initial presence. Preserve the question rounds, saved answers, skip, and reload assertions; production behavior and bilingual release notes are unchanged.
2026-09-29 22:38:08 +08:00
mesalogo ed47e2c2a5 chore: prepare desktop 0.15.4 and agent 0.15.1 releases
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 layered Runtime and model selection, separate system-task usage, and Continue history handling. Record schema 49 backup requirements and current-source Host validation with explicit failed-provider attempts.

Release note: Desktop 0.15.4 supports layered Runtime/model choices and separate system-task usage; update Desktop before Agent 0.15.1 to receive the remote Continue history fix.
2026-09-29 22:10:20 +08:00
mesalogo 9c88ca20f3 feat: layer runtime and model selection across conversation, project and global
- Store runtime selection as { provider?, model? } layers and resolve them per
  field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
  link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
  Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
  purpose-specific models.
2026-09-29 21:33:06 +08:00
mesalogo d1b92deff3 docs: update unreleased article through 86347af 2026-09-29 21:32:41 +08:00
mesalogo 9a9170441e feat: separate system task token usage and add usage refresh
Add a System tasks tab to run history that splits background usage (supervisor, auto-supervision, magic notes, knowledge base, delegation) from conversation usage, with a manual refresh button and automatic refresh when usage is recorded. Record previously dropped usage from supervisor reviews, image regeneration, knowledge graph extraction, and remote embeddings.
2026-09-29 10:38:37 +08:00
mesalogo 3c9229ac7a release: prepare Desktop 0.15.3
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
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
Document restored direct-model compaction, request-lifetime tool access, clearer cancellation and sidebar controls, reduced redundant startup work, and slimmer package inputs. Keep published Agent 0.15.0 and prior release notes unchanged. Review both feature matrices and retain Office editing as planned work. Typecheck, lint and bilingual notes validation passed; 85 focused tests passed and one POSIX-only test was skipped on Windows. Full source validation and production build remain CI gates before tagging.
2026-09-28 22:52:26 +08:00
mesalogo 86347af0b1 fix: refine sidebar search and composer shortcut hints 2026-09-28 22:38:35 +08:00
mesalogo 4baf4ec142 fix: restore manual context compaction for direct models
Local direct-model text conversations could not use manual context compaction,
and disabling automatic compression prevented saved summaries from being reused.
Restore the composer action regardless of the automatic setting or threshold,
use the selected summary connection, and persist summaries for subsequent
requests while retaining recent turns and full conversation history.

Image connections, remote channel conversations, and SSH projects remain outside
the local direct-model compaction workflow. Add focused regressions, a local
Electron end-to-end test, and an opt-in two-request live probe.

Validation: staged snapshot passed typecheck, changed-file ESLint, 13 focused
tests, and the Electron end-to-end test with two local HTTP fixture requests.
No additional real-model calls were made during commit verification.

Release note: 修复本地直连文本模型无法手动压缩上下文的问题。现在关闭自动压缩或尚未达到触发阈值时,也可点击“压缩上下文”;摘要会随会话保存并用于后续对话,同时保留最近轮次和完整历史。
2026-09-28 22:21:47 +08:00
mesalogo 1603871f7c fix: keep tool authorization valid throughout long requests
Remove the fixed gateway capability lifetime so active requests retain knowledge, notes, browser, configuration, and custom MCP access. Preserve token validation and request cancellation or cleanup revocation, and replace the misleading knowledge expiry error with a tool authorization error.

Remove native-client clock overrides and cover long-running scoped and HTTP MCP calls. Validation: 34 focused tests passed; changed-file lint passed. Repository typecheck and lint remain blocked by unrelated manual-compaction and sidebar probe files.

Release note: Long-running Agent requests no longer lose tool access after ten minutes, including when the knowledge-base switch is off. Request cancellation and completion still revoke access.
2026-09-28 21:36:41 +08:00
mesalogo 393a07ccdf fix: keep note capture actions compact and right-aligned
Remove initial generic validation instructions, show size errors beside their fields, and expose capture scope through contextual help. Keep save errors beside actions where space allows and preserve content-sized Save buttons in narrow note workspaces.

Release note: Note capture now uses a compact action bar with right-aligned buttons, on-demand scope help, and field-specific validation. Narrow workspaces no longer stretch the Save button.
2026-09-28 21:30:03 +08:00
mesalogo 50e8fde64c fix: soften sidebar dividers and tighten spacing 2026-09-28 20:54:56 +08:00
mesalogo 13ff182858 fix: reduce redundant work during chat and note startup
Avoid hidden activity renders, no-op message saves and attachment scans when pinning conversations. Reuse note body inspection and skip unchanged attachment reference writes while preserving recovery and cleanup behavior.

Skip unnecessary Markdown normalization and memoize subagent lookups without delaying live rich text. Add storage operation counts and real Electron rendering equivalence benchmarks; full-render speedup remains unproven.

Validation: 5397 tests passed, 84 skipped; typecheck passed. Lint passed excluding two existing untracked sidebar probe files.

Release note: Reduce repeated rendering and disk work during chat, conversation pinning and note startup while preserving existing content and interactions.
2026-09-28 20:53:43 +08:00
mesalogo 594222a2b5 fix: reduce desktop package size and loose files
Exclude validation outputs from desktop packages, ship renderer-only dependencies through Vite bundles, and omit DS Web type declarations, source maps, and PDB symbols. Preserve runtime assets and licenses without changing dependency versions.

Validate builder filters, production build and type checks, Windows packaged runtime smoke paths, and App page loading. Record measured size reductions and remaining validation limits in BUILD.md.

Release note: 减少桌面安装包中的重复依赖和非运行文件,降低安装后的磁盘占用与零散文件数量。
2026-09-28 20:10:19 +08:00
mesalogo 1c6264dd87 fix: group worktree diff actions with the file path
Move file actions above the divider alongside the path, allowing wrapping in narrow panels. Hide the redundant worktree heading unless staged changes also need a separate section.

Release note: Worktree diffs now group file actions above the divider and omit the redundant heading when only unstaged changes are shown.
2026-09-28 19:27:52 +08:00
mesalogo 120076a13d fix: group conversation search and standardize sidebar spacing
Move search to the top of the conversation area, remove its visible heading, and use a white surface in the light theme. Keep the list independently scrollable and sidebar controls reachable in short windows. Standardize footer divider spacing to 16px on both sides and update layout tests and design guidance.

Validation: 307 focused tests, typecheck, targeted ESLint, and four Electron layout scenarios passed.

Release note: Conversation search now sits above the conversation list in a distinct themed area, with consistent divider spacing and improved short-window scrolling.
2026-09-28 19:06:06 +08:00
mesalogo d8f0ef1301 fix: make browser Go button icon-only
Remove the visible Go label and use the toolbar icon-button width while preserving the tooltip and accessible name.
2026-09-28 19:00:44 +08:00
mesalogo 10f3b4f2c1 fix: center sidebar footer divider and correct Apps arrow
Use equal-width Apps and Settings buttons so the divider stays centered. Increase divider visibility and point the Apps arrow upward to match the menu opening direction.

Release note: Make the sidebar footer divider clearer and centered, and align the Apps arrow with its upward-opening menu.
2026-09-28 18:38:01 +08:00
mesalogo 58e6808f1f fix: deduplicate terminal notices and distinguish cancellation
Keep cancellation and failure details in message status while preserving existing replies. Render cancellation neutrally, restore terminal metadata from history, and offer Edit again only when input can be restored.

Release note: Cancelled and failed requests show a single notice; cancellation no longer appears as an error, and Edit again accurately describes the recovery action.
2026-09-28 18:10:10 +08:00
mesalogo 8650907b75 fix: distinguish Apps and Settings in the sidebar footer
Keep the horizontal footer layout with visible labels, matching button heights, a subtle divider, and an Apps dropdown indicator. Use a layout icon for Manage Apps so it is distinct from global Settings.

Release note: Make the bottom-left Apps and Settings entries easier to distinguish with labeled buttons and separate visual boundaries.
2026-09-28 18:08:52 +08:00
mesalogo c11f204b17 fix: align conversation list scrollbar with notes
Use a thin native scrollbar with a transparent track and theme colors for the conversation list on fine-pointer devices. Preserve system defaults for coarse pointers and forced colors.

Release note: The conversation list now uses the same subtle scrollbar style as notes, with native touch and high-contrast behavior preserved.
2026-09-28 18:06:20 +08:00
mesalogo be23c1346b docs: define live Office editing requirements and acceptance
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
Specify same-session Agent edits, visible changes, and Word, Excel, and PowerPoint acceptance scenarios. Record centralized deployment and ONLYOFFICE API licensing prerequisites as design requirements, not implemented capabilities.
2026-09-28 16:59:51 +08:00
146 changed files with 7745 additions and 2628 deletions
+24
View File
@@ -182,6 +182,30 @@ npm run dist:linux:arm64
已由 Vite 打包的 `@antv/g6` 归类为构建期依赖,不会再作为独立 Node 模块复制进桌面
安装包。
Desktop packaging includes only `out/main`, `out/preload`, and `out/renderer`;
validation builds elsewhere under `out` are not shipped. Renderer-only React,
Xterm, Mermaid, Fabric, Quill, i18next, and Markdown/math dependencies are build-time
dependencies: Vite ships their browser bundles, while the existing license
resources remain included. Install development dependencies before building;
do not prune them before bundling and copying license resources.
The desktop DS Web resource filter excludes JavaScript/TypeScript source maps,
`.d.ts` / `.d.mts` / `.d.cts` declarations, and `.pdb` debug symbols. It preserves
the installed dependency layout, plugin configuration, browser assets, licenses,
native binaries, and non-code `.map` assets. This filter does not change the
separately built remote Agent or Runtime payloads.
Windows x64 development validation on 2026-09-28 compared the existing 0.15.2
portable directory (excluding `data`) with a fresh unpacked test build:
`app.asar` decreased from 609.4 to 350.5 MiB, and DS Web decreased from 28,701
files / 463.4 MiB to 14,984 files / 357.0 MiB. Total measured size decreased from
1,758.3 to 1,438.4 MiB despite the test build retaining 45.6 MiB more Electron
locales than the portable build. These are unpacked sizes, not installer download
sizes. Builder-copy regression tests, production build/type checks, packaged
Harness/OpenCode/npm smoke checks, DS Web authentication/page scripts/workspace
creation, and packaged App/settings/notes-page loading passed. Full editor/canvas
interaction and other platform packages were not revalidated in this check.
以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
`agent-release-keys.json`,但所有桌面构建路径(包括本地 `portable`)都不会嵌入
+42 -12
View File
@@ -64,7 +64,9 @@ records are listed separately and do not introduce another feature status.
- [x] **Compact conversation controls**: Composer options share a compact
settings panel while Runtime, mode, sending, and queue controls remain
available. Conversation search has an inline clear action that restores the
scoped list and returns focus to the input.
scoped list and returns focus to the input. New conversation and expandable
search share one row. Composer shortcut hints reflect successful registration
and saved settings, without being overwritten by late startup responses.
- [x] **On-demand conversation history**: Lists load lightweight summaries
rather than every conversation's process metadata. Opened and active
conversations retain full details; search, copy, export, continued requests,
@@ -141,10 +143,11 @@ records are listed separately and do not introduce another feature status.
models, approval policy, budgets, and timeouts in a Project, with reusable
templates.
- [x] **Application center and navigation**:
Clicking the bottom App Center opens a lightweight upward anchored popup with enabled apps,
The footer shows equal-height Apps and Settings buttons with visible labels and a subtle vertical divider.
Clicking Apps, marked with an upward chevron, opens a lightweight upward anchored popup with enabled apps,
regardless of pinning or opening history, and no modal backdrop. Clicking an app row closes
the popup; Local Inference Monitor opens a separate modal preserving the workspace, while other apps
open their main content pages. Manage Apps opens searchable cards with settings details;
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.
Cards, the launcher, and the filtered sidebar share one persisted order. Knowledge
@@ -330,7 +333,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.0`, paired with the current Desktop release candidate `0.15.2`; formal
`0.15.1`, paired with the current Desktop release candidate `0.15.4`; 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
@@ -467,10 +470,16 @@ records are listed separately and do not introduce another feature status.
direct models report their own backoff phase. Footer dots are static, and
unsupported Runtime retry details are not invented. Conversation-list
indicators and send/stop behavior remain unchanged.
- [x] **Simplified Runtime selection**: Conversation and project menus no
longer enumerate every Runtime/model combination. Configure Runtime models
in system settings; saved fixed project selections remain usable and
explicitly display their fixed model.
Cancellation and failure retain existing replies and use a single status;
cancellation is neutral, and Edit again appears only when input can be restored.
- [x] **Layered Runtime and model selection**: Resolve Runtime and model per
field through conversation, project, and global defaults. The two-column
composer picker shows sources and supports overrides or restoring project
defaults; project and channel settings share the same rules. Main resolves
each request again, including queued dispatch. Deleted or incompatible
connections fall back, while missing credentials remain explicit errors.
Schema 49 migrates stored choices. Purpose-specific models remain planned;
see the [selection design](./docs/features/model-connections/runtime-selection-design.md).
- [x] **Role-bound model connections**: Each role can inherit the default model
or select an independent text-model connection. Invalid connections safely
fall back to the default; the synthesis role always inherits the default.
@@ -495,7 +504,12 @@ records are listed separately and do not introduce another feature status.
fields take precedence, and these ordinary connection settings must not be
used to store API keys or other secrets. See
[model request customization](./docs/features/model-connections/README.md).
- [x] **Context usage and automatic compaction**: Direct models update usage
- [x] **Context usage and compaction**: Local direct text-model conversations
also support manual compaction below the automatic threshold or with automatic
compaction disabled. The selected summary model may incur charges; recent turns
and full stored history remain available, and saved summaries are reused.
Image connections and remote direct-model entry points are excluded.
Direct models update usage
from each successful provider call. Images and tool rounds use the same
accounting, with estimation only when the provider omits usage. The UI
distinguishes This Model Call from Post-Compaction Conversation Estimate,
@@ -757,7 +771,11 @@ records are listed separately and do not introduce another feature status.
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Project and conversation
usage groups show collapsed totals by default and expand to Runtime/model child
rows; model grouping remains flat. Activity is grouped
rows; model grouping remains flat. A separate System tasks tab groups
background usage by source, with manual refresh and updates when usage is
recorded. Supervisor reviews, image regeneration, graph extraction, and remote
embeddings feed usage records; missing historical/provider data is not reconstructed.
Activity is grouped
by conversation and collapsed by default so long histories do not fill the
page.
- [x] **Compact conversation tool records**: Expand individual tool records
@@ -833,7 +851,7 @@ records are listed separately and do not introduce another feature status.
- [ ] **Batch runs and comparison lab** (planned): Compares model, Prompt,
role, and workflow configurations in batches and summarizes quality,
duration, tokens, cost, failure rate, and artifact differences.
- **Story Graph read tools (unreleased source)**: Agents can search saved work,
- **Story Graph read tools (Desktop 0.15.2 / Agent 0.15.0)**: Agents can search saved work,
read current claims or timelines, and page through source evidence. Supervisor
enablement and Runtime assignment control discovery and calls, including old
sessions. Model, local Harness, and local/remote OpenCode and Continue share
@@ -920,9 +938,21 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases
- The current Desktop candidate is `0.15.2` and the Agent candidate is `0.15.0`, with
- The current candidates are Desktop `0.15.4` and Agent `0.15.1`, 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.4` introduces layered Runtime/model choices and separate
system-task usage, and reduces duplicate Runtime instructions and history.
Schema 49 migrates project/conversation choices; rollback requires the complete
pre-upgrade database and adjacent notes backup. Agent `0.15.1` requires Desktop
`0.15.4` and fixes remote Continue history wrapping without changing locked
Runtime versions. See the [0.15.4 preparation record](./docs/development/release-preparation-0.15.4.md).
- Desktop `0.15.3` restores local direct-model manual compaction, retains tool
authorization for the request lifetime, clarifies cancellation and sidebar/note
controls, avoids redundant startup/storage work, and trims duplicated package
dependencies and development files. It does not claim a proven overall rendering
speedup. Office live editing remains a design, not a delivered capability.
See the [0.15.3 preparation record](./docs/development/release-preparation-0.15.3.md).
- Desktop `0.15.1` carries forward the unpublished `0.15.0` Obsidian integration, conversation capture into Magic
Notes, and preview local native clients, plus knowledge, Supervisor, and UI
fixes. Schema 48 adds conversation-source metadata; older clients require a
+9 -6
View File
@@ -36,7 +36,7 @@
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
- [x] **应用中心与导航**:底部应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻筛选。点击应用行关闭菜单,本机推理监控打开保留底层工作区的独立 Modal,其余应用打开主内容区;底部“管理应用”打开可搜索卡片及设置详情,布局与交互见 [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)。
- [ ] **私有化应用市场**(规划中):在应用管理基础上提供组织自己的目录与应用分发。当前只管理内置应用,尚无市场浏览或安装能力,详见 [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 +52,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.0`,配套 Desktop 候选为 `0.15.2`;详细运行边界和 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.1`,配套 Desktop 候选为 `0.15.4`;详细运行边界和 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。
@@ -69,10 +69,11 @@
- [x] **OpenCode 事件连接回收**:聊天与原生上下文压缩在结束事件迭代前关闭各自订阅,覆盖完成、失败、取消和消费方提前结束,不取消其他并行会话。
- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
- [x] **简化 Runtime 选择**:会话与项目菜单不再枚举每个 Runtime 与模型的组合,Runtime 模型在系统设置中配置;已保存的项目固定模型仍可使用,并明确显示其固定模型。
- [x] **Runtime 与模型分层选择**:按会话、项目、全局逐字段解析 Runtime 和模型。会话双栏选择器显示来源,可覆盖或恢复项目默认;项目与通道设置共用规则,Main 在每次请求及排队派发时重新解析。已删除或不兼容的连接会回退,缺少凭据仍明确报错。schema 49 迁移已保存选择,用途模型仍为规划,详见[选择设计](./docs/features/model-connections/runtime-selection-design.md)。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
- [x] **本地直连手动压缩**:文本模型会话可在自动压缩关闭或未到阈值时手动生成摘要,保留最近轮次和完整历史,保存的摘要用于后续对话;图像连接和远程直连入口不适用。摘要模型调用可能产生费用。
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
@@ -115,7 +116,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;用量按项目或会话分组时默认收起并显示汇总,展开后显示 Runtime / 模型子行,按模型分组时保持平铺。独立“系统任务”页签按来源汇总后台用量,支持手动刷新和记录更新后刷新;监督回顾、图片重新生成、图谱提取和远程嵌入补充用量记录,不补算缺失的历史或供应商数据。活动按会话分组并默认收起,避免长历史占满页面。
- [x] **紧凑会话工具记录**:逐项展开工具记录即可查看和复制结果、错误及输入参数;会话与子任务进度使用简洁行,减少重复 Runtime 摘要。OpenCode、Continue、DeepSeek Harness 和直连模型可显示已有文件路径、命令或搜索摘要,并在工具完成和会话重载后保留。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
@@ -129,7 +130,7 @@
- [ ] **监督后续能力**(规划中):逐事件完整回放、Experiment 目标、人工修改时间序列审计、事件触发及更完整的执行观察仍未完成。当前自动回顾受输入预算和滚动窗口限制,删除报告不会重置处理进度或重建图谱。详见 [会话监督 PRD](./docs/features/conversation-supervision/prd.md)。
- [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。
- [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。
- **Story Graph 只读工具(未发布源码)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
- **Story Graph 只读工具(Desktop 0.15.2 / Agent 0.15.0)**:Agent 可搜索已保存的工作,读取当前主张或时间线,并分页核对来源。监督者应用开关及 Runtime 分配同时控制发现和调用,旧会话也受限制。Model、本地 Harness、本地与远程 OpenCode/Continue 共用桌面读取入口;查询不调用模型,不以较新回顾推断替代关系。跨 scope 事实复用及 `as_of` 仍待实现,详见[设计与验证](./docs/features/conversation-supervision/story-graph-mcp-design.md)。
- [ ] **可视化受控工作流**(规划中):提供版本化 DAG、条件分支、审批、取消和恢复,执行节点继续经过 Main Runtime 边界。
- [ ] **统一 Run Graph 与回放**(规划中):关联任务、Subagent、模型、知识、工具审批、用量和成果,支持失败定位、重试和脱敏导出。
@@ -153,7 +154,9 @@
### 开源、构建与发布
- 当前 Desktop 候选为 `0.15.2`,Agent 候选为 `0.15.0`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
- 当前候选为 Desktop `0.15.4` 与 Agent `0.15.1`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 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)。
- Desktop `0.15.1` 承接未发布的 `0.15.0` Obsidian 接入、会话采集到魔法笔记和预览中的本地原生客户端,并修复知识库、监督者与界面操作。schema 48 新增会话来源元数据,回退旧客户端需使用升级前完整备份。Agent `0.15.0` 要求 Desktop `0.15.2`,保留 Node `24.19.0`,补齐远程遥测关闭配置及 Continue 跳过已关闭的更新检查,不新增远程客户端快捷入口。
- Desktop `0.13.15` 承接未发布的 `0.13.14`,改善监督者分页回顾与续跑、ripgrep 原生搜索、待办布局和远程结果未知后的显式续聊。数据库升级至 schema 47,回退旧客户端需使用升级前完整备份。
+8 -4
View File
@@ -293,11 +293,15 @@
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
@@ -328,7 +332,7 @@
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 侧栏底部提供应用中心入口,设置按钮独立靠右。点击应用中心向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”打开既有整窗管理 Modal,共同应用设置沿用设置 Modal。
- 侧栏底部提供“应用 / Apps”和“设置 / Settings”两个横向等高 `nav-item` 按钮,均显示图标和文字,中间使用弱竖分隔线。“应用”带向下箭头,点击后向上展开轻量锚定菜单,无模态遮罩,只列已启用应用,不按打开历史或常驻状态筛选;点击行关闭菜单,知识库、智能心跳和魔法笔记在主内容区打开页面。本机推理监控从菜单、常驻入口或管理 Modal 打开时均使用独立 Modal,保留底层工作区。菜单底部“管理应用”使用 `LayoutGrid` 图标,打开既有整窗管理 Modal,标题仍为“应用中心 / Application Center”,共同应用设置沿用设置 Modal。
四个内置应用的管理列表、菜单和侧栏共用保存顺序,菜单按启用过滤,侧栏按启用且常驻过滤。知识库、智能心跳始终启用并常驻,可调整位置;管理 Modal 提供“打开”和排序,不提供启用、常驻开关或通用空设置,标签为“始终显示”(英文 `Always shown`)。原工作区配置及心跳单条计划 enabled 保持不变。只有魔法笔记、本机推理监控可修改启用和常驻,取消常驻不关闭功能。当前对话、运行记录和设置保持固定系统位置,不参与应用排序。
设置持久化后的事件同步各入口及工具配置写入;重开中心刷新期间锁定修改,新快照不被旧读取覆盖。文件、终端、浏览器和资源监控只通过右侧工作栏访问,不在左侧复制入口。
管理 Modal 始终使用紧凑单列横向列表,纵向对应保存顺序;左侧为图标、名称及简短用途,右侧为打开、可用的设置及上下移动操作,行间使用弱分隔线,不加卡片外框。窄窗口操作换行且不溢出,保留共享键盘焦点环。可选应用关闭时禁用“打开”。启用、常驻位于紧凑设置详情;四行均提供上下移动及拖动排序,不受启用或常驻影响,箭头按完整顺序判断边界。搜索时禁用拖动,箭头仍按完整顺序移动,不丢失未命中项。尺寸、返回与滚动重置规则见[应用中心 UI 设计](./docs/features/application-tool-navigation/ui-design.md)。已有稳定设置入口时,不在顶栏重复提供同一入口。
@@ -375,7 +379,7 @@
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
- 浏览器 Tab 的内容顶部固定使用一条紧凑工具栏,按“返回、刷新/停止加载、地址输入、
前往、交互、关闭浏览器”排列。“刷新”在当前页面加载时原位切换为“停止加载”;
前往、交互、关闭浏览器”排列。“前往”使用纯右箭头图标按钮,保留悬停提示和可访问名称。“刷新”在当前页面加载时原位切换为“停止加载”;
“关闭浏览器”必须使用明确文字和与页面导航控件清楚区分的危险操作视觉,不能继续标为
含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿,
不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让
@@ -695,7 +699,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。
- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 空输入框使用三行原生占位文案:第一行为消息提示(生图模式保留图片描述提示),第二行为“Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴”,第三行为“Ctrl+N 新建对话 · 实际已注册快捷键 快捷唤起”。新建与粘贴快捷键按平台显示,macOS 使用 Command;全局快捷键不可用时省略该项。输入文字后占位提示自然隐藏,输入框下方不再重复全局快捷键,保留模式、安全边界和其他元信息。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
- 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.15.0",
"agentVersion": "0.15.1",
"protocol": {
"major": 2,
"minor": 0
@@ -0,0 +1,36 @@
# Desktop 0.15.3 发布准备
日期:2026-09-28。用户确认沿用双远端推送、精确提交 CI 和生产构建通过后自动打标签的流程;
本次仅发布 Desktop 0.15.3,已发布 Agent 0.15.0 保持不变,不构建 LoongArch。
## 范围
- 已发布基线:`v0.15.2`,提交 `47ba54f47db4f22f69375310925eedab020b3cf0`。
- 本轮已核对 15 个提交,截止 `86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`。
包含手动压缩、请求级工具授权、取消/失败状态、聊天/笔记重复工作、
侧栏与工作栏布局、打包依赖精简。起始工作区干净。
- Office 同会话实时编辑仅更新需求与验收设计,不计入交付能力。
- `package.json`、lockfile 两处 Desktop 版本统一为 0.15.3。
已发布 0.15.2 与 Agent 0.15.0 的正文保持不变;新双语说明单独追加。
- 两份 FEATURES 对照实现更新手动压缩、状态反馈、搜索、箭头方向及发布状态。
不用局部 Markdown 基准宣称整体渲染性能提升,不提前承诺跨平台安装包缩小比例。
## Agent 与验证边界
`src/agent-daemon`、`src/main/remote-agent`、Agent 和 Runtime 锁在本轮未变。
授权修复属于 Desktop 的 `KnowledgeMcpGateway`:请求有效期间不再按固定十分钟撤销,
取消和清理继续撤销;不修改远程工具通道或部署包,不需要重发 Agent 0.15.0。
手动直连压缩仅用于本地文本会话,UI 与 IPC 均排除 SSH/远程直连入口。
开发期证据分别见[直连模型进度](../features/direct-model-agent/progress.md)、
[导航进度](../features/application-tool-navigation/progress.md)和
[性能基准](../roadmap/product-performance-experience-improvement-plan.md)。
开发期全量曾报告侧栏和笔记 Electron 用例失败,不把这些历史结果计为本候选通过。
本轮 `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
网关、Obsidian 授权、桌面资源与 DS 打包、手动压缩真实 Electron 生产链路共 6 文件:
**85 通过、1 跳过**。跳过的是 Windows 不适用的 POSIX 符号链接用例。
压缩测试使用本机 HTTP fixture,不冒充外部模型调用;本轮没有新增付费模型请求。
沿用用户确认的直接推送等待 CI 流程,完整测试及生产构建以精确候选 CI 为发布门槛。
不进行本地生产构建、打包或安装包启动。
schema 保持 48;从更早版本升级前备份完整数据库及相邻 `notes/` 目录。
@@ -0,0 +1,55 @@
# Desktop 0.15.4 与 Agent 0.15.1 发布准备
日期:2026-09-29。用户确认两个补丁版本,提交并推送两个远端;
候选 CI 和生产构建成功后自动打标签发布,不构建 LoongArch。
## 范围与说明
- Desktop 已发布基线为 `v0.15.3`(`3c9229ac7a57071fc955adb1095c499ffdfd006a`)。
本轮源码截止 `9c88ca20f3c62ca29b9aca179dc191b460db37d8`,开始时工作区干净。
- 三个提交包含系统任务 Token 统计、Runtime/模型分层和宣传稿归档。
正式功能说明以实现为准;用途模型和旧全局 provider 移除仍为规划。
- Agent 基线为已发布 `agent-v0.15.0`。本轮 Continue helper 识别首次历史封装、
去除保存消息中的工作模式前缀,因此发布新 Agent 0.15.1,不覆盖旧产物。
Node 24.19.0、OpenCode 1.18.29、Continue 1.5.47 和协议锁保持不变。
- 正文仅维护在 Desktop 0.15.4 JSON 条目及 Agent 0.15.1 Markdown;
已发布版本正文保留。FEATURES 中修正旧的简化选择描述,并同步用量、版本和限制。
## 数据与兼容
schema 49 在既有 SQLite 事务中迁移项目与会话选择,不引入额外恢复系统。
已读迁移指南并核对合成 schema 48 数据迁移回归;本轮不改写用户原库。
关闭应用后备份完整数据库及相邻 `notes/`,旧客户端不能打开升级后的库,
降级需要恢复完整升级前备份。远程先升级 Desktop 0.15.4,再更新 Agent 0.15.1。
统计无法补算历史未记录的调用或缺失的供应商 usage。
## 验证
- `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 通过。
- 10 个聚焦文件、221 项通过:包含数据库迁移/用量分类、选择解析、选择器、
Activity UI、后台用量记录和 Continue helper。
- 当前源码隔离 Agent 在共享 Linux x64 Host 验证 OpenCode 与 Continue Ask、Execute、
空闲进程回收及冷启动历史恢复。成功场景每个 Runtime 两次真实模型请求。
OpenCode 原模型连接前三次尝试未完成,第三次脱敏诊断明确为上游 `ECONNRESET`;
随后测试显式使用 Continue 已配置且成功的模型连接,未修改用户设置或产品回退规则。
本轮共 7 次上游请求分发尝试,4 次成功、3 次未完成;不推断失败尝试的计费情况。
所有探针结束时归属进程为 0,用户设置只在临时加密副本中读取,测试副本已清理。
- 既有开发记录缺少完整 UI 目测及全仓通过结果,保留边界,不将局部通过冒充全量。
完整测试与生产构建交由精确提交 CI;不执行本地生产构建、打包或安装包启动。
## 首轮候选 CI
`ed47e2c` 已同步两个远端。Agent `36580699297` 三平台验证通过。
Desktop `36580699255` 为 5,472 通过、52 跳过、1 失败,生产构建未执行,
尚未创建任何本轮发布标签。问答测试在 Runtime 就绪前点击仍禁用的发送按钮,
没有产生首个请求;按现有异步同步机制等待按钮可用,保留全部问答与重载断言。
该修正仅影响测试和发布记录,运行时源码、锁文件及双语发布说明不变。
修正后完整 `App.test.tsx` 314 项通过,类型检查与 lint 再次通过。
## 发布前界面修正
用户要求移除会话 Runtime 按钮图标与名称间的圆点,并确认继续提交、推送和发布。
删除专用覆盖标记 class 及其伪元素样式,保留菜单内来源提示和恢复默认操作,
同步双语说明及选择设计。完整 App 与样式回归共 351 项通过,类型检查和 lint 通过。
该改动仅在 Renderer,不影响 Agent 或桌面至 Agent 协议,无需新增真实模型请求。
此前中断的测试不计为通过;本条结果来自重新执行。最终新提交仍须等待独立 CI。
@@ -1,7 +1,7 @@
# 应用导航与系统工具入口
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,四个内置应用共用可调整的保存顺序,知识库始终启用并常驻;
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部应用中心向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
智能心跳、魔法笔记和本机推理监控可通过应用管理控制启用和常驻。底部“应用”向上展开轻量锚定菜单,列出已启用应用;底部“管理应用”打开既有“应用中心”管理 Modal。系统工具由右侧工作栏 Tab 承载。应用商店是后续功能,首期不提供
商店入口、推荐位或安装流程。
当前分期见 [PRD 第 8 节](./prd.md#8-分期),实施步骤见[技术设计第 11 节](./technical-design.md#11-实施顺序)。
@@ -86,7 +86,7 @@
### FR-5 应用中心入口
侧栏底部提供应用中心和独立设置入口。点击应用中心向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
侧栏底部提供“应用”(英文 `Apps`)和独立“设置”入口,两个按钮均显示文字。点击“应用”向上展开锚定按钮的轻量菜单,无模态遮罩。菜单列出已启用应用,不按是否打开过、历史记录或常驻状态筛选;点击应用行关闭菜单,本机推理监控打开 Modal,其余应用在主内容区打开页面。
菜单底部“管理应用”关闭菜单并打开既有管理 Modal,在其中浏览、搜索全部内置应用,为已关闭的可选应用提供设置与重新开启入口。无搜索结果时显示空状态并可清除搜索。
首期不展示商店、安装、推荐或系统工具清单。入口具有可访问名称,Escape 关闭后恢复触发位置的焦点。
@@ -1,5 +1,56 @@
# 应用导航实施进度
## 2026-09-28: Composer shortcut initialization follow-up
The composer now retains successful shortcut-settings snapshots independently of
the startup app-info request. A failed or pending app-info read no longer drops a
successful shortcut save, and a late startup response cannot restore a disabled
hint. Registration remains authoritative; no default shortcut is advertised when
registration is unavailable. This preserves the existing [composer rule](../../../UI-DESIGN.md#131-聊天).
The failed/pending read regressions reproduced the missing hint before the fix.
The reported screenshot's actual registration status and IPC result were not
captured, so its specific runtime cause remains unconfirmed.
Validation: App, sidebar layout, main IPC, and shortcut-service suites passed
(500 tests, one skipped); `npm run typecheck`, ESLint on the four changed source
and test files, and `git diff --check` passed. The first broader run hit the
60-second command limit; the rerun completed in 95 seconds. No full-repository
suite, native Electron screenshot reproduction, packaging, or OS registration
probe was run for this follow-up.
## 2026-09-28:新建对话与搜索共用一行
已按[会话搜索](./ui-design.md#会话搜索)接入收起与展开状态,移除列表上方的常驻搜索头部。
沿用会话创建、延迟搜索和项目过滤接口。后续按用户反馈恢复默认按钮的聊天加号图标、原有
字号字重和整体左对齐;搜索展开后保留居中的同一图标。快捷键说明移至聊天输入框原生占位文案,
具体规则见[聊天 UI 规范](../../../UI-DESIGN.md#131-聊天)。
本次调整验证:`App.test.tsx` 与 `AppSidebarLayout.test.ts` 合计 316 项通过;最终长标签换行
调整后,布局 5 项与 `magic-notes-capture.electron.test.ts` 1 项复验通过。Windows Electron
在上述 6 种侧栏组合检查原字号字重、左对齐、图标居中、文字无溢出及搜索交互,并验证
原生输入后占位提示隐藏、清空后恢复。App 回归覆盖中英文、生图提示、macOS 快捷键格式、
全局快捷键缺省以及设置保存后的更新和停用。类型检查、修改文件 ESLint 与差异空白检查通过。
未运行全仓测试、打包或 macOS/Linux 原生窗口验证。模型调用 0 次。
此前搜索展开改动的验证记录:
```text
npm test -- src/renderer/src/App.test.tsx src/renderer/src/AppSidebarLayout.test.ts tests/magic-notes-capture.electron.test.ts
npm run typecheck
```
三文件合计 311 项通过。最终动画边界与文字宽度调整后,布局及 Electron 共 5 项、App 搜索
交互 1 项复跑通过,typecheck 通过。Electron 复用现有 App、生产 Preload、IPC 和临时 SQLite
测试环境,以英文界面检查浅深主题下 220px、420px 侧栏和短窗口抽屉,共 6 种组合;检查
展开过程及结束后的右边缘、列表位置、完整按钮文字、原生鼠标与 Escape、自动聚焦、关闭
焦点恢复、点击列表保留关键词及减少动态效果。中文 App 回归同时覆盖标题、正文、项目
过滤和加号创建。模型调用 0 次。
修改文件的 ESLint 与 `git diff --check` 通过。全仓 ESLint 有 10 个错误,均来自本任务未修改
的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或
macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。
## 2026-09-28:并行会话输出时保持列表顺序
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
@@ -175,17 +175,21 @@ Main 继续校验可信 sender、Zod 输入,复用 `ApplicationSettingsStore`
原 `user-card` 整行设置按钮已替换为容器和相邻独立按钮:
```tsx
<div className="sidebar-footer">
<button onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
{t('navigation.applicationCenter')}
<div className="sidebar-footer sidebar-footer--applications">
<button className="nav-item" onClick={toggleApplicationMenu} aria-haspopup="menu" aria-expanded={applicationMenuOpen} ...>
<Grid2X2 aria-hidden="true" />
<span>{t('applications.menuLabel')}</span>
<ChevronUp aria-hidden="true" />
</button>
<button aria-label={t('navigation.settings')} ...>
<div className="sidebar-footer__divider" aria-hidden="true" />
<button className="nav-item" aria-label={t('navigation.settings')} ...>
<Settings aria-hidden="true" />
<span>{t('navigation.settings')}</span>
</button>
</div>
```
应用中心和设置为相邻独立按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
“应用”(`applications.menuLabel`:中文“应用”、英文 `Apps`)和设置为横向等高独立按钮,使用 `nav-item` 样式和弱竖分隔线;“管理应用”菜单项使用 `LayoutGrid` 图标,管理 Modal 标题保持“应用中心” / `Application Center`。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
保留设置预加载、焦点恢复和未保存离开确认。该调用只打开设置 Modal,不更新当前工作区或
KeepAlive 缓存;`settingsOpen` 独立管理打开状态。关闭后卸载设置内容,下次按入口指定的分类
和消息通道重新挂载,避免缓存上次分类覆盖本次直达目标。底层工作区继续挂载,普通关闭不跳回聊天。
@@ -21,8 +21,7 @@
品牌
项目选择
项目活动汇总(仅有活动时显示)
新对话
搜索
[新对话] [搜索图标]
当前对话
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
@@ -32,24 +31,37 @@
...
────────────────────────
[应用中心] [设置]
[应用 v] | [设置]
```
“当前对话”和“运行记录”属于固定系统入口,常驻应用位于二者之间。首期应用清单为魔法笔记、
知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
### 会话搜索
新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标;
点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。
最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用
`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。
通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。
控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。
展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框,
焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。
无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。
## 2. 底部应用中心入口
底部使用连续平整表面和顶部分隔线,应用中心在前,固定设置靠右,二者为独立按钮:
底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间:
| 位置 | 图标 | 可见工具提示 | 点击结果 |
| 位置 | 图标 | 可见文字 | 点击结果 |
| --- | --- | --- | --- |
| 1 | `Grid2X2` | 应用中心 | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
| 末位 | `Settings` | 设置 | 在当前工作区上打开设置 Modal |
| 1 | `Grid2X2` + `ChevronUp` | 应用 / Apps | 向上展开已启用应用菜单,底部“管理应用”进入管理 Modal |
| 末位 | `Settings` | 设置 / Settings | 在当前工作区上打开设置 Modal |
图标使用项目现有 Lucide 库。应用中心保留文字,设置图标采用统一的 `34px × 34px` 点击区域。
底部使用共享间距和焦点样式,设置通过弹性空白靠右;不得把整个容器做成设置按钮。
图标使用项目现有 Lucide 库。两个按钮均保留可见文字,沿用主导航的高度、间距和焦点样式;
“应用”文字后显示向上箭头,与菜单展开方向一致。竖分隔线宽 1px、高 `--space-6`,使用 `--border-default`,与上方横分隔线颜色一致。不得把整个容器做成设置按钮。
### 2.1 状态
@@ -84,7 +96,7 @@
菜单通过共享 `AnchoredMenu` 挂载到 body,宽度最多 `280px`,距窗口边缘至少 `16px`,与按钮上缘间隔 `8px`;短窗口限制高度并内部滚动,滚动、窗口和内容尺寸变化时重算位置。原生浏览器相交遮挡复用共享机制。
管理 Modal 沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
“管理应用”菜单项使用 `LayoutGrid` 图标。管理 Modal 标题仍为“应用中心”(英文 `Application Center`),沿用设置 Modal 的表面、遮罩和焦点规则,提供全部内置应用的名称及用途搜索、打开,以及可选应用的常驻和设置:
应用管理始终采用紧凑单列横向列表,从上到下对应保存顺序;行间使用 `--border-subtle` 弱分隔线,不加卡片外框。概览与设置详情共用 `640px` 最大宽度,窄窗口保留两侧各 `16px` 外边距,高度随内容增长并受窗口限制。
每行左侧显示图标、名称、状态和简短用途,右侧对齐打开、可用的设置及上下移动操作;空间不足时操作组换到下一行并保持右对齐,组内也可换行,上下箭头保持成组。名称旁区分
@@ -189,7 +201,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7
| 对象 | 文案 |
| --- | --- |
| 底部入口 | 应用中心 |
| 底部入口 | 应用 / Apps |
| 管理 Modal 标题 | 应用中心 / Application Center |
| 菜单底部管理入口 | 管理应用 |
| 应用设置开关 | 启用应用 |
| 文件系统工具 | 文件 |
@@ -42,7 +42,7 @@
- Given 任意主页面和任意侧栏宽度
- When 用户查看侧栏底部
- Then 设置与应用中心为始终可见的独立按钮,点击区域不重叠
- Then “设置”与“应用”为始终显示文字的独立按钮,点击区域不重叠
- And 点击后继续遵守未保存内容的离开确认
## 3. Epic B:管理应用入口
+1
View File
@@ -447,6 +447,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
- 文件预览提供复制项目相对路径、刷新,以及仅本机项目可用的“使用默认应用打开”。
刷新失败保留此前内容并显示错误;Markdown 默认渲染预览,可切换源码。
- Diff 提供刷新、复制项目相对路径和“查看当前文件”;删除项不显示查看当前文件入口。
文件路径与操作按钮位于分隔线上方,空间不足时换行;只有未暂存差异时省略“工作树更改”标题,同时存在已暂存差异时保留分区标题。
- 手动刷新或任务结束更新 Git 状态时,同步重读根目录、当前浏览目录和已展开目录并去重,保留目录展开状态,
刷新失败保留已有目录列表;重新展开目录时重读。目录读取错误在对应目录旁显示,
可就地重试。正在查看的单文件 Diff 同时重读,旧请求不得覆盖新结果。
@@ -1,5 +1,38 @@
# 工作栏实现与验证进度
## 2026-09-28:消息取消与失败展示
消息终态按[Runtime 交互边界](./runtime-interactions.md)保存和展示。取消使用结构化
`terminalStatus`,保持已有回答;终止提示不再补入正文。“重新编辑”只回填并聚焦输入框,
没有可回填文字时隐藏入口。旧快照只对精确重复的形态隐藏状态副本,保留原正文。
深色主题已移除覆盖失败提示颜色的通用规则;检索和压缩记录不再屏蔽终态的无障碍角色。
Windows 本地验证:
- 本次修复完成时 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。
后续并行的应用入口修改新增 `App.test.tsx:14430` 的 `getByRole(..., { exact: true })`,
最后一次类型检查报 TS2769;该选项不属于 `ByRoleOptions`。未修改这处他人新增代码,
当前组合工作区不能记为类型检查通过。最后一次 lint 和 diff 检查通过。
- `npm test`:5,367 通过、84 跳过、1 失败。失败项为
`tests/magic-notes-layout.electron.test.ts` 的原生拖拽测试,90 秒超时;单独复跑仍超时。
该文件未修改,本次检查不记为全量通过。
- 相关五文件复跑为 447 通过、14 失败;14 项均在并行入口改名期间查找“应用”按钮失败。
入口源码更新后,单独复跑 `src/renderer/src/App.test.tsx` 的 304 项全部通过。
其余四文件(数据库、时间线、清单和终态 Electron 探针)的 157 项在上述复跑中通过。
- `tests/terminal-status-probe.electron.test.ts` 通过真实 App、Preload、生产 IPC、
直连 Runtime 和 SQLite 验证空回答取消、部分回答取消、HTTP 400 失败;检查提示唯一性、
浅深主题 computed style、重新编辑回填与焦点,以及数据库重开和页面恢复。
每轮使用 3 次本地确定性 HTTP 请求,外部模型调用 0 次。探针在结束时关闭 Runtime、
HTTP 服务和窗口,并删除本轮独立临时目录。
- 数据库回归覆盖恢复事件事务回滚、失败与取消重放、空正文和已有回答保留、重开后元数据。
App 和时间线回归覆盖新事件结构化判定、旧消息精确去重、正常正文保留、无效按钮隐藏、
活动及工具终态。
本次未连接远程主机,未创建远程资源。远端事件仍通过既有公共事件进入桌面的
`reduceRecoveredAgentEvent`,本次仅修改其消息投影与元数据保存,不改变 Agent、SSH
传输、取消执行、ACK 或重连控制;本地 SQLite 恢复测试覆盖该修改。未验证 Linux/macOS
原生界面,不把本地探针记为远端取消生命周期验收。
## 2026-09-28:DS Web 资源符号链接与 macOS 发布恢复
Desktop `v0.15.0` 原生构建中,两个 macOS 目标均因包外符号链接未通过签名校验。
@@ -100,6 +100,16 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的
会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。
- 失败和取消的终止提示只进入消息状态,不补写正文或文本块,已有回答、推理与工具输出
保留。取消使用中性色和礼貌级 `status`,真实失败使用危险色和 `alert`;运行记录的
取消结果标题为“任务已取消”。工具详情已表达同一失败时,不再复制到消息底部。
- 最新失败或取消消息只有在上一条用户消息含可回填文字时显示“重新编辑”。点击只回填
输入框并聚焦,由用户修改后自行发送;附件单独发送且无文字时不显示此按钮。
- 实时事件和 Main 历史恢复均将事件的 `failed / cancelled` 写入消息元数据
`terminalStatus`;既有 `state: error` 继续表示未成功终态,不改 SQLite 枚举。
新事件不按提示文案识别取消。旧快照无此字段时,仅兼容已发布的精确取消标签。
旧记录若正文与状态完全相同,且无文本块或唯一文本块位于末尾并等于该状态,展示时
保留正文、隐藏重复状态;不修改持久数据,不删除正文中的同名句子或部分匹配内容。
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
的原生重试状态。直连模型的重试等待与请求开始文案见
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
@@ -261,6 +261,29 @@ DeepSeek Harness 和托管 SSH 路径不注入这些工具。既有功能的 Win
MCP 代理复用 Provider 发现快照规则,托管 SSH Runtime 不经过该本机构造路径。本次未
修改远端 Agent、桥接或启动器。
## 2026-09-28 本地直连手动压缩
- 本地直连文本会话复用“压缩上下文”按钮、摘要状态和会话保存路径,支持关闭自动压缩时
手动执行。当前连接选择、图像排除、强制压缩和最新轮次保留规则见
[技术设计](./technical-design.md#101-本地直连手动压缩)。未修改远端 Agent、桥接或启动器;
model 入口排除远程会话与 SSH 项目,本地 IPC 仍拒绝 SSH 执行空间。
- 三个目标文件 `App.test.tsx`、`ipc.test.ts`、`model-runtime.test.ts`:569 passed、2 skipped。
覆盖入口点击、自动关闭和低于阈值、运行中禁用、图像排除、摘要保存与后续请求、当前及指定
摘要连接选择。补入远程通道入口排除后,App 手动压缩专项复跑 7 passed;最终类型检查和
本次文件 ESLint 再次通过。
- `manual-compaction.electron.test.ts`:真实 App、preload、IPC、模型工厂和 SQLite 路径通过。
2 次本机 HTTP fixture 请求验证较早历史被摘要替换、最新轮次保留、完整历史仍在数据库,
页面重载后摘要可读,新建 Runtime 实际使用该摘要。
- `manual-compaction-live.electron.test.ts`:真实配置文本模型最终通过,2 次请求完成摘要及
新 Runtime 续问。三轮探针累计 6 次真实文本模型请求;前两轮失败来自探针用未转义摘要
匹配 JSON payload,修正断言后通过。原设置文件未改变,未记录凭据。
- `npm run typecheck` 和本次涉及文件的 ESLint 通过。全仓 `npm run lint` 有 10 个错误,
位于已有侧栏探针 `sidebar-final-probe.ts`、`sidebar-final-run.mjs`,保留未修改。
- 全量 `npm test`:5407 passed、85 skipped、2 failed。失败分别为已有侧栏探针的新建按钮
溢出断言(`magic-notes-capture.electron.test.ts`)及魔法笔记布局 Electron 超时
(`magic-notes-layout.electron.test.ts`);手动压缩 Electron 回归通过。未执行构建或跨平台
真机测试,不将全仓验证记为全绿。
## 进度维护要求
- 只有真实生产路径完成并验证后才能勾选实施项。
@@ -451,6 +451,18 @@ type SubagentActor =
处理。Continue 接收完整历史,已有有效摘要只替换其覆盖的前缀,剩余消息全部交给原生
Runtime;GoodBuddy 不再额外保留最后 20 条或限制为 128,000 字符。
### 10.1 本地直连手动压缩
`agentCompactConversation` 接受 `model`,结果保留实际 provider,压缩策略为
`goodbuddy-summary`。摘要来源为 `current` 时,IPC 使用 `applyRuntimeSelection`
解析后的当前直连连接;来源为 `profile` 时,使用既有指定摘要连接配置。
图像连接不进入文本摘要路径,SSH 执行空间仍由本地 IPC 拒绝。
`ModelAgentRuntime.compactConversation` 复用 `prepareCompressedRequest` 的强制压缩,
跳过自动开关和触发阈值,保留最新轮次。返回的 `contextCompressionState` 沿用会话保存,
不删除历史正文。后续请求校验摘要覆盖范围后,用摘要替换已覆盖前缀;即使关闭自动压缩,
已保存的有效摘要仍会使用。
## 11. 性能
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
@@ -21,6 +21,11 @@
Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由或操作菜单。
本地直连文本会话复用输入区已有的“压缩上下文”按钮,不依赖原生 Runtime 快照,
也不因自动压缩关闭或上下文低于触发阈值而隐藏。运行中或压缩中按钮禁用;图像连接、
远程通道会话和 SSH 项目不显示直连模型压缩入口。完成后沿用应用通知及压缩后上下文估算,
摘要随会话保存。压缩行为见[技术设计](./technical-design.md#101-本地直连手动压缩)。
## 2. 设置中的能力目录
直连模型工具目录使用两个本机开发分组:
@@ -64,6 +64,10 @@ Main 先保存资产,再保存草稿或接受队列;消息、分支建立独
会话删除、项目删除及清空数据均核对引用,启动回收已无归属的记录和孤立 UUID 目录。
清理失败保留无引用数据库记录供下次重试,不回滚已接受的草稿。
引用保存按输入顺序去重并查询资产。已有引用的顺序、会话归属均相同,且资产无需从临时目录
转存时,直接返回,不重复开启写事务或删除、插入引用。空引用同样适用;顺序、归属或附件
发生变化时仍按原事务更新,临时资产仍须转存。
助手库 schema 39 标记新附件元数据,阻止旧客户端将其当旧格式读取;没有批量改写历史附件。
知识库 schema 13 新增 `metadata.parsedResultId` 表达式索引,结果按主键定位,不扫描全部文档。
知识库新结果先写入独立 resultId 目录,数据库正文/chunks 发布成功后替换引用,再清理旧目录。
+1 -1
View File
@@ -6,7 +6,7 @@
FR-2、FR-3:单条采集读取所选助手消息的非空正文;整段采集先加载该会话完整历史,再按消息顺序收录用户和助手的非空正文,保留角色分隔。预览建立后,切换会话或收到新消息不会替换正文及来源标识。生成中的消息不可采集,停止或失败后已有正文可保存,单条预览注明回答未完成。整个会话正在生成时禁用整段采集,提示等待完成或停止生成。
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览持续说明保存范围。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
首版按文字保存,保留原始 Markdown 文本、换行和代码文本,不承诺还原聊天渲染效果。推理、工具调用日志、系统提示、附件二进制和图片不进入正文;预览来源旁提供保存范围帮助,按需展开。用户可编辑预览,来源仍指向本次采集的会话。读取完整会话失败时不以屏幕上已有消息代替。
FR-5:来源独立于可编辑正文。单条来源包含会话和消息标识;整段来源包含会话标识、此次收录的消息标识及采集时间。预览使用采集时的会话标题和项目名称;保存时主进程校验消息归属,并以当时数据库中的会话标题和项目名称写入来源,无项目时显示全局。若预览期间源会话或消息已删除,保存失败并保留预览。保存完成后,修改或删除源消息不回写笔记正文和来源。
+25
View File
@@ -1,5 +1,30 @@
# Magic Notes Progress
## 2026-09-28: Startup Body Read Reuse
Startup repair now reuses the asset references collected during revision
inspection, as described in [file reconciliation](./technical-design.md#schema-38-migration).
The persisted format and revision recovery rules are unchanged.
- Real temporary SQLite/filesystem fixture: 120 entries across 120 notes,
half rich text and half canvas, with 4,039,560 bytes of body JSON.
Five initializations each read 240 bodies before and 120 after the change.
Logical bytes read fell from 8,079,120 to 4,039,560 per initialization.
- Baseline durations were 148.23, 134.42, 131.06, 140.66 and 172.90 ms;
after durations were 136.89, 119.11, 116.41, 115.14 and 116.26 ms.
Medians were 140.66 and 116.41 ms. These Windows runs used warm filesystem
caches on a shared machine; they do not measure cold application startup.
- `npx vitest run src/main/magic-notes/magic-note-storage.test.ts src/main/assistant/assistant-database.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant-storage-startup.test.ts tests/assistant-storage-worker.test.ts`
passed 154 tests. Coverage includes failed media inspection, missing/invalid
bodies, retained shared assets, revision mismatch recovery and migration.
- Full `npm test`: 5382 passed, 84 skipped, 3 failed. The failures were the
App question-round request wait and Electron note capture focus/layout waits.
All three passed in targeted reruns; the full run was not all green.
The capture rerun exercised production App/preload/IPC/SQLite and reopening.
- `npm run typecheck` passed. Full lint still reports 10 errors in the existing
untracked `sidebar-final-probe.ts` and `sidebar-final-run.mjs`; lint excluding
those two files passed. No provider calls were made for these storage benchmarks.
## 2026-09-27: Shared Destructive Button Styles
The draft discard Portal now receives the global `danger-solid` styles. The
+13 -1
View File
@@ -173,7 +173,12 @@ empty content. Failed creates leave no accepted database membership; their
unaccepted files are removed on the next access or reopen.
After commit, reconciliation derives `note.json` from database membership and
scans all retained entry files before removing unreferenced assets. Manifest or
inspects all retained entry files before removing unreferenced assets. Startup
repair reads each current body once to inspect its revision and collect asset
references, then passes the complete per-note reference set to reconciliation.
If media inspection fails, cleanup uses its existing deferred retry path rather
than trusting a partial set. Revision mismatch recovery still hydrates the body;
legacy conversion still verifies its written content. Manifest or
GC failures log `console.warn` with the note ID and error and remain in an
in-memory retry set. Later database access retries; reopening derives cleanup
again from membership. These cleanup failures do not turn a committed save,
@@ -469,6 +474,13 @@ The built-in MCP gateway returns invalid scoped-tool arguments as an
the model to correct its arguments without an MCP internal-error response.
Validation failures do not execute the tool.
Note tools can be authorized with the knowledge-base switch off. The shared
desktop gateway binds authorization to each request, without a fixed elapsed-time
expiry, so long-running requests retain tool access. Request completion,
cancellation, failure cleanup, or gateway disposal revokes the token. Invalid or
revoked tokens report unavailable tool authorization rather than a knowledge-base
expiry error.
These note tools run in the desktop gateway. Remote gbagent ACP sessions
currently inject only the image MCP server, so this change requires no
daemon implementation update.
+4 -2
View File
@@ -24,7 +24,7 @@
```text
加入笔记
来自:项目 / 会话标题 · 单条回答或当前对话
保存范围:用户与助手正文,不含推理、工具日志和附件文件
来源旁帮助按钮:按需查看保存范围
[可编辑文字预览]
@@ -35,7 +35,9 @@
[取消] [加入笔记]
```
范围说明随单条或整段来源调整。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时就地提示,并禁用保存;普通新建允许创建空笔记。
范围说明随单条或整段来源调整,通过来源旁的 `InlineHelp` 按需查看;回答未完成的警告仍直接显示。预览初始包含完整待保存文字,可滚动编辑。新建目标标题默认取会话标题前 100 个字符,可修改;已有目标与新建目标使用共享 `SegmentedControl` 切换。采集内容为空或超限、缺少目标、新建标题为空或超限时禁用保存;初始不显示通用校验说明,正文和标题超限时在对应字段旁提示。普通新建允许创建空笔记。
底栏默认仅显示右对齐的操作按钮。保存失败时保留草稿,错误与按钮在宽面板中同行,窄面板中错误换行、按钮仍靠右。完整工作区的文字保存按钮在窄窗口中也保持内容宽度并右对齐。
预览和快速追加输入框通过无障碍名称“待加入内容”标识,不再显示重复标签。提交区仅显示校验或保存错误,不显示“全局”。提交与来源跳转按钮组均居右,可在窄面板换行;来源没有项目名时省略项目及分隔符,不补写“全局”。
@@ -10,6 +10,7 @@ Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明
| --- | --- |
| [UI 设计](./ui-design.md) | 编辑入口、校验反馈、兼容性说明和无障碍行为 |
| [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 |
| [执行方式与模型选择](./runtime-selection-design.md) | 会话/项目/全局分层解析、选择器行为、存储迁移、全局 `provider` 移除计划和用途模型规划 |
| [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 |
模型连接选择准确性也在本目录维护:技术设计第 8 节定义通道直连回退修复,
@@ -1,5 +1,32 @@
# 模型连接请求定制进度
## 2026-09-29:执行方式与模型分层选择
设计见[执行方式与模型选择设计](./runtime-selection-design.md)。
### 已完成(未提交)
- 共享选择层契约、`resolveRuntimeChoice` 分层解析及失效回退;移除 `auto`。
- 数据库版本 49 迁移旧选择形状;连接删除后清理所有项目和会话的失效引用。
- Main 每次请求按会话层、项目层重新解析,不回写会话;排队输入在派发时重新解析。
- 项目设置 `ProjectRuntimeSelector`(本地、远程、通道共用),输入框两栏
`RuntimeModelPicker`;系统设置恢复各 Runtime 页签的默认模型选择。
- 选择器修复:模型列不再与底栏重叠,长名称单行截断;“管理模型连接…”直接打开模型连接分类。
### 已验证
- `npm run typecheck` 与相关文件 eslint 通过。
- Main、Shared、Daemon 测试集通过;`App.test.tsx` 与 `WorkspacePrimitives.test.tsx`
350 项中 349 项通过,唯一失败为既有不稳定用例“renders, saves and reloads OpenCode
answers across question rounds”,单独运行通过。
### 待办
- 在运行中的应用里目测两栏选择器(窄窗口、深色主题、长名称)。
- 移除全局 `provider`,见[设计第 6 节](./runtime-selection-design.md#6-全局-provider历史遗留待移除)。
- 用途模型,见[设计第 7 节](./runtime-selection-design.md#7-用途模型规划未实现)。
## 2026-09-27:窄容器协议显示
连接管理布局改用命名容器查询,不超过 `640px` 时列表和表单上下排列;移除原先仅在
@@ -0,0 +1,157 @@
# 执行方式与模型选择设计
本文件是执行方式(Runtime)与模型选择的唯一权威说明,覆盖分层解析、各入口的界面
行为、存储迁移,以及尚未实现的“用途模型”规划。连接本身的请求定制见
[技术设计](./technical-design.md),DeepSeek Harness 的平台来源解析见
[Harness 技术设计](../deepseek-harness/technical-design.md#121-配置来源)。
## 1. 术语
| 术语 | 定义 |
| --- | --- |
| 执行方式 | `provider`:`model`(直连模型)、`opencode`、`continue`、`deepseek-harness` |
| 模型选择 | `{ kind: 'profile', profileId }` 或 `{ kind: 'runtime-config' }`(Runtime 自有配置) |
| 选择层 | `RuntimeSelectionLayer = { provider?, model? }`,两个字段都可缺省 |
| 全局默认模型 | 每个执行方式在“系统设置”中的默认模型:直连用 `defaultModelProfileId`,其余用 `opencodeModelSource` / `continueModelSource` / `deepseekHarnessModelSource` |
| 具体选择 | 解析结果 `AgentRuntimeSelection`,包含确定的 `provider`,以及 `profileId` 或 `runtimeConfig: true` 之一 |
## 2. 分层与优先级(已实现)
选择按“会话 → 项目 → 全局”逐字段解析,入口为共享函数 `resolveRuntimeChoice`
(`src/shared/runtime-selection-contracts.ts`):
1. **执行方式**:会话层 `provider` → 项目层 `provider` → 全局兜底(见第 6 节)。
2. **模型**:只继承与最终执行方式一致的层。会话单独切换执行方式时,丢弃会话层模型,
沿用项目层(若项目执行方式相同)或该执行方式的全局默认模型。
3. 结果附带 `providerSource` / `modelSource`(`global`、`project`、`conversation`),
界面据此显示来源标记。
失效处理:
| 所选模型状态 | 行为 |
| --- | --- |
| 可用 | 使用 |
| `unavailable`(缺少 API Key) | 保留选择;运行时明确报告缺少凭据,不静默换模型 |
| `missing`(连接已删除) | 回退到下一层,并在选择器中提示回退原因 |
| `incompatible`(协议或能力不匹配) | 同上 |
- 图像生成连接只对直连模型有效,且永远不作为回退默认值。
- 远程项目只允许 OpenCode 与 Continue;其他执行方式按 `provider` 原因回退。
- 全局 `defaultModelProfileId` 为空时解析为 `{ provider: 'model' }`,不生成空 `profileId`。
- 不存在 `auto`。旧 `{ provider: 'auto' }` 在读取和 IPC 入口处去除,按上述顺序解析。
## 3. 各入口行为(已实现)
### 3.1 项目设置
本地、远程和通道项目共用 `ProjectRuntimeSelector`:
- “执行方式”必选,没有“继承”选项;保存时总是写入具体 `provider`
(`ProjectSwitcher` 的 `withProjectProvider`、`ChannelProjectSettingsFields` 的
`channelProjectDraft`)。
- “模型”默认项为“跟随 xx 默认(…)”,其余为该执行方式可用的连接及“使用 xx 自有配置”。
- 新建会话跟随项目,不再提供“默认执行方式”四选一。
### 3.2 输入框选择器
`RuntimeModelPicker` 为两栏菜单:左栏执行方式,右栏当前高亮执行方式的模型。
- 选择只写入会话层;左栏选择与项目相同的执行方式时写入 `undefined`,保持继承。
- 右栏首项为“默认(…)”,带来源标记(全局 / 项目);已删除或不兼容的连接禁用,缺少
API Key 的连接可选并标注。
- 会话存在单独选择时,底栏显示“本会话单独选择”和“恢复为项目默认”;输入区按钮不在图标与 Runtime 名称之间添加圆点。
- 模型列按最长列表预留高度,悬停切换执行方式时菜单不跳动;列内超出部分滚动,底栏
固定在两栏下方。长名称单行截断,完整名称和模型 ID 通过 `title` 与可访问名称提供。
- “管理模型连接…”打开“设置 → 模型连接”。
- 键盘:左右键在两栏间切换,上下 / Home / End 在栏内移动,Esc 关闭并还焦点。
### 3.3 系统设置
“设置 → Agent Runtime”按 OpenCode / Continue / DeepSeek Harness 页签分别选择各自的
全局默认模型;直连模型的默认值在“模型连接”中设置。设置页没有“全局执行方式”控件。
## 4. 执行时解析(已实现)
- Main 的 `resolveStoredRuntimeSelection` / `resolveLayeredRuntimeSelection` 在每次请求时
读取会话层与项目层重新解析,不把解析结果回写到会话记录。
- 排队的输入在真正派发时重新解析,不使用入队时的快照。
- 定时任务的选择在创建时冻结在任务上;运行时优先任务选择,再到项目层。
- 远程事件的 context metrics 按本次请求实际使用的具体选择记录
(`remoteEventSelection`);未传入时数据库按会话层、项目层解析,缺省执行方式为
OpenCode。
## 5. 存储与迁移(已实现)
- `projects.runtime_selection_json` 与 `conversations.runtime_selection_json` 保存紧凑
的选择层;空层保存为 `NULL`。
- 数据库版本 49(`migrateRuntimeSelectionLayers`)把旧的 `{ provider, profileId }` 和
`{ provider: 'auto' }` 改写为选择层;读取时 schema 预处理也会升级旧形状。
- `repairConversationRuntimeSelections` 在连接被删除后清除所有项目与会话中的失效
`profileId`。
- 通道项目创建为 `{ provider: 'model' }`;SSH 项目允许不保存选择。
## 6. 全局 `provider`(历史遗留,待移除)
`RuntimeSettings.provider` 来自项目层出现之前的“全局执行方式”。它已没有设置界面,
但仍在以下位置被读取:
| 位置 | 用途 |
| --- | --- |
| `resolveRuntimeChoice` 的 `normalizedGlobalProvider` | 项目和会话都没有 `provider` 时的兜底(旧数据、无项目的状态查询) |
| `getConfiguredRuntimeTarget` → `createConfiguredRuntime`(`src/main/index.ts`) | 启动和设置变更时创建默认 Runtime;监督者回顾、无唯一项目的心跳等不带项目的请求使用它 |
| `runtime-settings-store.ts` | 归一化、`auto` → `model` 迁移和写回 |
| `SettingsPanel` | 无界面地读入并原样保存 |
结论:该字段不应继续作为用户概念存在。移除方案与第 7 节一起实施:
1. 启动修复为所有 `provider` 为空的项目写入当时解析出的具体执行方式。
2. 兜底固定为 `model`;默认 Runtime 固定为直连模型。
3. 设置输入和公开设置不再包含 `provider`;存储保留该字段只用于读取旧文件,不再写入。
## 7. 用途模型(规划,未实现)
### 7.1 问题
后台功能各自隐式决定模型,用户无法控制,也无法从界面得知:
| 用途 | 当前行为 | 是否需要工具 |
| --- | --- | --- |
| 监督者回顾与整理 | 全局 `provider` 对应的默认 Runtime 与模型 | 否,工具调用被拒绝 |
| 心跳报告 | 单一项目时跟随项目,否则同上 | 否 |
| Magic Notes 分析 | 直连默认模型连接 | 否 |
| 知识图谱抽取 | 直连默认模型连接(HTTP) | 否 |
| 上下文压缩 | 已有 `contextCompression.modelSource`(当前模型 / 指定连接) | 否 |
| 子代理 | 直连模型,可按专家覆盖 | 是,保持现状 |
| 定时任务 | 任务或项目选择 | 是,保持现状 |
### 7.2 决策
- 用途只选择**模型连接**,统一走直连模型,不提供执行方式选择。上述用途都是无工具的
文本请求,为其启动 OpenCode / Continue / DeepSeek Harness 只增加延迟、成本和故障面。
- 每个用途的取值为“跟随默认模型”或一个文本模型连接;图像连接不可选。
- 需要工具的子代理和定时任务不纳入本设置。
### 7.3 范围
“设置 → 模型连接”新增“用途模型”小节,包含:
- 监督者回顾与整理
- 心跳报告(无唯一项目时)
- Magic Notes 分析
- 知识图谱抽取
- 上下文压缩(迁入现有 `contextCompression.modelSource`,语义不变)
### 7.4 解析与失效
- 解析:用途设置的连接 → 全局 `defaultModelProfileId`。
- 连接被删除:设置修复为“跟随默认模型”,与第 5 节的失效清理一致。
- 连接缺少 API Key:保留选择,运行时明确失败,不静默改用默认模型。
- 心跳在有唯一项目时仍跟随项目选择;用途设置只替代原先的全局兜底。
### 7.5 待定
- 设置字段形状(建议 `purposeModels: Record<Purpose, { kind: 'default' } | { kind: 'profile', profileId }>`)
与设置文件版本号。
- `contextCompression.modelSource` 的 `current`(跟随当前会话模型)是否保留为压缩用途
专有的第三个选项。
- 监督者模型并发(`supervisorModelConcurrency`)是否与所选连接绑定显示。
+3 -4
View File
@@ -52,10 +52,9 @@ OpenCode 与 DeepSeek Harness 不使用该值。
## 会话与项目 Runtime 选择
会话与项目选择器只提供直连模型和各 Runtime 入口,不再枚举每个 Runtime 与每个模型的
组合;Runtime 模型在系统设置中配置。已保存的固定 Runtime 模型选择继续保留,项目
选择器的选项与帮助文字均显示其固定模型,不能误报为跟随全局设置;用户可选择普通
Runtime 入口恢复跟随。该展示规则不改变模型解析、远程 Agent 协议或执行权限。
项目设置分“执行方式”和“模型”两个字段,输入框使用两栏选择器,全局默认模型仍在系统
设置中按 Runtime 页签配置。分层规则、选择器状态、键盘行为和失效提示由
[执行方式与模型选择设计](./runtime-selection-design.md#3-各入口行为已实现)唯一维护。
## Harness 模型来源展示
+61 -9
View File
@@ -5,10 +5,10 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-09 |
| 版本 | 0.2 |
| 日期 | 2026-09-28 |
| 适用产品 | GoodBuddy Desktop、ShareServer |
| 推荐编辑引擎 | ONLYOFFICE Docs Developer Edition,最终采用前完成许可证与分发审核 |
| 推荐编辑引擎 | 首选 ONLYOFFICE Docs Developer Edition;正式采用前验证实时编辑接口并确认授权与报价 |
## 1. 背景
@@ -21,7 +21,8 @@ XLSX 和 PPTX,也无法把当前选区交给 Agent 后在原位置应用修改
- Windows、macOS 和 Linux Desktop 使用同一套浏览器编辑能力。
- DOCX、XLSX、PPTX 在助手工作栏中以多个独立文档页签打开。
- 用户可以手动编辑、保存、撤销和重做。
- Agent 可以读取用户明确选择的内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
- 用户在左侧与 Agent 对话,在右侧助手工作栏看到当前文档的修改状态和结果,无需重新打开文件。
- Agent 可以读取用户明确选择的最新内容,并将一次 AI 修改作为一个可撤销编辑事务应用。
- 本地源文件只在用户明确打开后上传工作副本,保存时通过 Desktop 原子回写。
- ShareServer 统一承载编辑器接入、短期会话、工作副本、回调、保留和服务端审计。
@@ -66,10 +67,18 @@ ONLYOFFICE 保存先形成 ShareServer 保存检查点。Desktop 下载并验证
### FR-6 AI 选区编辑
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器只提供
本次选区的有界结构和必要上下文。Agent 返回编辑器支持的结构化操作,由编辑器插件应用;一次
请求形成一个撤销事务,并在完成后标出修改位置。只有 Execute 模式可以应用修改;Ask 模式
可以生成建议,但不得调用编辑器写入。
用户在编辑器中明确选择文字、单元格、对象、页面或幻灯片后,可以发起 AI 编辑。编辑器提供
当前会话内本次选区的最新内容、有界结构和必要上下文,包括尚未保存到源文件的人工修改。
Agent 通过当前编辑会话应用受支持的结构化操作;一次请求形成一个撤销事务。只有 Execute
模式可以应用修改;Ask 模式可以生成建议,但不得调用编辑器写入。
首期有界操作至少覆盖选区读取、指定文本替换、单元格区域公式与数字格式设置,以及指定幻灯片
文本和对象属性修改。操作前明确目标文件与范围,完成后返回实际修改结果;不支持的操作说明
限制,不以模型生成了建议或工具请求已发出作为成功依据。
Agent 思考期间用户可以继续编辑。应用前检查当前文档版本与目标是否仍符合本次请求;内容变化
或目标失效时停止应用旧结果,保留人工修改并提示基于最新内容重试。切换页签或选区不得将已
提交请求改发到其他目标。已打开文档的交互修改不得通过直接重写源文件并重载编辑器实现。
### FR-7 权限与数据去向
@@ -102,6 +111,34 @@ Desktop 记录本地打开、保存、另存为、冲突、AI 编辑请求和终
Desktop 只连接用户已明确添加并认证的 ShareServer。ONLYOFFICE 地址、JWT 密钥、对象存储和
回调配置由 ShareServer 管理,不进入 Desktop Renderer 或普通设置导出。
推荐由部署方在服务器 Docker 中独立运行 ONLYOFFICE Document Server,客户端加载编辑器;
普通用户无需安装 Docker。启用前验证文件下载、保存回调和中文字体,并说明工作副本会进入
所选服务的处理范围。完全离线编辑不属于此部署方案。
### FR-13 Agent 修改可见性
右侧文档页签与 Agent 使用同一个编辑会话。请求处理中显示当前目标与执行状态;编辑器确认
应用后,内容在当前页签直接更新,用户可定位并看到修改范围的临时高亮。状态提示和高亮不写入
文档正文,执行完成不等于源文件已保存。用户可以继续手动编辑,并对该次 AI 修改撤销或重做。
实时可见以一次已确认的编辑事务为单位,不要求模拟逐字输入。失败、取消或结果未知应明确
显示实际状态;提交前取消不修改文档,已应用的事务通过撤销恢复,结果未知时先核实再重试。
### FR-14 选型验证与授权
ONLYOFFICE 是统一承载 Word、Excel、PPT 的首选方案。正式采用前,必须在同一嵌入编辑会话中
验证选区读取、人工修改反馈、Agent 写入、修改定位与高亮、撤销重做,以及保存后重新打开。
验证目标见第 6 节,不能以编辑器可打开或转换成功代替 Agent 编辑验证。
优先验证 Automation API 与 GoodBuddy 编辑器插件的配合方式。根据
[官方 Automation API 文档](https://api.onlyoffice.com/docs/docs-api/usage-api/automation-api/)
(2026-09-28 核对),该接口仅适用于 Docs Developer,且需额外付费。选型需取得覆盖桌面嵌入、
私有部署、并发连接、分发和所需接口的授权条件及报价;社区版容器部署成功不代表取得这些权益。
插件独立接入可作为备选验证,不预先认定与 Automation API 等价或免除商业授权要求。
三类文件的实际接口覆盖、单次撤销、高亮方式、中文字体与复杂格式兼容性,以及授权成本均为
待验证项。未通过的操作记录具体限制,再决定补充实现或调整范围;本 PRD 不表示已有可用实现。
## 5. 文件范围
首期可编辑格式:
@@ -121,7 +158,22 @@ PDF 保持预览或文档处理用途,不进入首期 Office 编辑。宏格
- 同一工作栏至少可以同时保留两个独立文档页签,切换不会串文件、选区或保存状态。
- 同名文件的页签可辨认,页签标题和完整来源一致。
- 保存失败、回调失败和源文件冲突均不会破坏原文件。
- 每次 AI 修改可以单次撤销,拒绝或取消不会产生部分修改。
- 每次已完成 AI 修改可以单次撤销和重做;拒绝或提交前取消不修改文档,应用后取消按 FR-13 处理。
- ShareServer 不可用时已有本地文件仍可由默认应用打开,但不能伪装成内置编辑可用。
- Renderer、编辑器页面和日志中均不存在本机绝对路径、ShareServer 管理密钥或长期文件令牌。
- 文档会话过期后,旧启动 URL、文件 URL 和回调令牌均不可重用。
以下场景按 [用户故事](./user-stories.md) 的 `US-C1` 至 `US-C7`、`US-F1` 和 `US-F2` 验证;
记录编辑器版本、授权版本、样本和实际结果,不把待测能力标记为已通过。
| 样本/场景 | 操作 | 通过标准 |
| --- | --- | --- |
| Word | 改写选中段落 | 右侧原位更新,保留约定段落与文字格式,范围外内容不变 |
| Excel | 修改指定区域公式并设为两位小数 | 公式重算与预期一致,显示格式正确,区域外内容不变 |
| PPT | 修改指定页文本及一个对象属性 | 对象定位准确,非目标对象与布局不变 |
| 人工修改反馈 | 人工修改后不保存,立即发起 AI 请求 | Agent 读取到会话内最新内容 |
| 并行编辑 | Agent 思考期间用户修改文档 | 旧结果不覆盖新修改,支持基于最新内容重试 |
| 共同编辑流程 | 应用、定位、高亮、撤销、重做、保存并重新打开 | 当前页签无需重载即可显示结果;保存后的文件内容与预期一致 |
文件兼容性使用包含中文字体、分页、表格、图表和公式的实际样本检查。目标平台的嵌入加载、
焦点、快捷键与复制粘贴一并验收;接口验证和授权确认完成后再进入完整工作栏与生产部署实施。
@@ -1,5 +1,9 @@
# Office 协同编辑用户故事
日期:2026-09-28。状态:需求设计,尚未完成实现与真实编辑器验证。
需求范围见 [PRD](./prd.md)。本轮补充 ONLYOFFICE 同会话编辑、右侧修改展示和选型验证用例。
## US-A1 打开本地文档
**Given** 用户已连接支持 Office 编辑的 ShareServer,并在工作区选择一个受支持文件
@@ -45,11 +49,15 @@
## US-C1 使用 AI 修改选区
关联:`FR-6`、`FR-13`。
**Given** 当前 Conversation 使用 Execute,用户在编辑器中选择受支持内容
**When** 用户提交 AI 修改要求
**Then** Agent 只接收有界选区和必要上下文,编辑器将完整结果作为一个事务应用并允许单次撤销。
**Then** Agent 只接收当前会话的最新有界选区和必要上下文,右侧显示目标与处理状态;编辑器
确认后原位更新、临时高亮并支持定位修改范围,无需重载。完整结果作为一个事务应用,可以
单次撤销和重做;高亮不进入正文,完成状态不表示已写回源文件。
## US-C2 Ask 模式请求修改
@@ -60,6 +68,65 @@
**Then** GoodBuddy 可以生成修改建议,但不会调用编辑器写入;界面提供切换 Execute 后重新应用
的明确入口。
## US-C3 读取尚未保存的人工修改
关联:`FR-6`。
**Given** 用户已在右侧修改文档,但尚未保存到源文件
**When** 用户选择刚修改的内容并要求 Agent 继续编辑
**Then** Agent 基于当前编辑会话中的最新内容执行;磁盘旧文件和旧检查点不能替代本次上下文。
## US-C4 Agent 思考期间继续编辑
关联:`FR-6`、`FR-8`。
**Given** Agent 已读取选区,正在生成修改
**When** 用户继续修改文档,或切换到另一个文档页签与选区
**Then** 切换页签不改变原请求目标;原文档版本变化或目标失效时不应用旧结果,保留人工修改并
提示基于最新内容重试。同一文档的其他页签也不能接收本次操作。源文件冲突仍按 `US-B2` 处理。
## US-C5 编辑 Word、Excel 和 PPT
关联:`FR-3`、`FR-6`、`FR-13`。
**Given** 已分别准备 DOCX、XLSX 和 PPTX 样本,当前 Conversation 使用 Execute
**When** 用户分别请求改写选中段落、修改选区公式及两位小数格式、修改指定幻灯片文本和对象属性
**Then** 按下表验证实际结果;每个样本均完成定位、高亮、单次撤销、重做、保存及重新打开检查。
| 样本 | 验收结果 |
| --- | --- |
| DOCX | 目标段落内容符合要求,约定文字与段落格式保留,范围外内容不变 |
| XLSX | 目标区域公式及重算值符合预期,数字显示为两位小数,区域外内容不变 |
| PPTX | 指定页面和对象定位正确,文本与属性变更符合要求,非目标对象及布局不变 |
## US-C6 不支持、取消和执行失败
关联:`FR-6`、`FR-13`。
**Given** 用户发起一次 AI 编辑请求
**When** 操作不受支持、用户取消或编辑器未能确认结果
**Then** 不支持时返回具体限制;提交前取消不修改文档,已应用事务提供撤销。未知结果明确显示
待核实,不自动重复写入,不以建议生成成功代替编辑成功;执行失败保留原因和实际已应用状态。
## US-C7 编辑状态与源文件保存
关联:`FR-4`、`FR-13`。
**Given** 右侧已显示 Agent 修改完成,用户又进行了一次人工编辑
**When** 用户保存文档并重新打开源文件
**Then** 保存包含已确认的 AI 修改与后续人工修改;只有源文件回写成功才显示“已写回源文件”。
回调或回写失败时保留失败状态及可用检查点,不因右侧已经更新而宣称保存完成。
## US-D1 网络中断后恢复
**Given** 编辑会话已经建立
@@ -83,3 +150,26 @@
**When** 用户继续访问旧页签
**Then** 新读取、保存和 AI 操作被拒绝,旧令牌不能重用,本地源文件不受服务端清理影响。
## US-F1 启用集中部署的 Office 服务
关联:`FR-7`、`FR-12`、`FR-14`。
**Given** 部署方已准备独立的 ONLYOFFICE 服务及所需授权,用户已连接 ShareServer
**When** 用户首次在 Desktop 打开 Office 文档
**Then** 无需在用户电脑安装 Docker;打开前能看到工作副本进入哪个服务及保留期限。实际验证
文件下载、保存回调和中文字体;在 Windows、macOS 和 Linux 检查加载、焦点、快捷键与复制粘贴。
## US-F2 决定正式采用 ONLYOFFICE
关联:`FR-14`。
**Given** 产品团队以 ONLYOFFICE Docs Developer 为首选,正在进行接口与授权验证
**When** 评审是否进入完整功能实施
**Then** 提供 `US-C1` 至 `US-C7` 的真实会话验证结果、三类文件样本兼容性记录,以及覆盖实际
部署与嵌入方式的授权条件和报价。Automation API 单独确认额外费用;插件替代方案单独验证。
未通过项明确列出限制与范围决定,仅安装容器、打开文档或完成转换不能视为实时编辑验证通过。
@@ -485,8 +485,10 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创
重连时,工具及原生子代理的状态也以已提交的远端活动事件为准,覆盖消息终态处理派生的
失败状态,避免将未完成活动误判为可恢复错误,保证终态重放内容一致。
- 远程 context metrics 与对话级压缩后的估算值按本次请求实际使用的 Runtime 选择写入
`context_state_json`。实时接收与恢复入口都传入请求的选择;数据库在未传入时按对话、
项目、auto 顺序解析。继承项目设置的对话保持 `runtime_selection_json = NULL`,
`context_state_json`。实时接收与恢复入口都传入请求的选择;数据库在未传入时按对话层、
项目层解析,缺省执行方式为 OpenCode(规则见
[执行方式与模型选择设计](../model-connections/runtime-selection-design.md#4-执行时解析已实现))。
继承项目设置的对话保持 `runtime_selection_json = NULL`,
不为接收 usage 固定对话 Runtime;去重和消息/指标写入仍在同一 SQLite 事务中完成。
- ACK 后 Agent 删除已确认事件,只保留小型操作终态与游标用于幂等核对;未 ACK 输出继续
保留。Main 按 transcript page 的最高连续 sequence ACK,终态仍立即 ACK。单事件、单页、
@@ -1,12 +1,12 @@
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.1,以及此后截至 `66f77b8` 的已提交变化,更新于 2026-09-28。所述功能与修复均已开发完成并提交;0.15.1 之后的提交归属版本待确认。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.2,以及此后截至 `86347af` 的已提交变化,更新于 2026-09-28。所述功能与修复均已开发完成并提交;0.15.2 之后的提交归属版本待确认。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者按项目回顾进展,并通过图谱保留结论依据;新加入的笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。远程 Runtime 配置修复需要先升级至 Desktop 0.15.1,再将 Host 环境更新至 Agent 0.15.0。下文新增的远程故事图谱读取还需要包含对应改动的 Desktop 与 Agent,配套版本见发布前核验项。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。远程故事图谱读取与 Runtime 配置修复配套 Desktop 0.15.2 和 Agent 0.15.0,请先升级桌面端,再更新 Host 环境。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.1`,对应 `4e52392`,承接未公开发布的 0.15.0;本轮增量为 `65a2139..66f77b8`。故事图谱读取、完整讨论正文编辑及笔记面板简化位于该 tag 之后,发布归属待确认;远程图谱读取需要同步包含 `66f77b8` 改动的 Desktop 与 Agent,不能标为 Agent 0.15.0 已有。对外发布前核验最终版本、公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag 为 `v0.15.2`,与 `agent-v0.15.0` 同指向 `47ba54f`;故事图谱读取、完整讨论正文编辑及上一轮笔记面板简化已纳入对应版本。本轮检查 `0cdf042..86347af`,手动压缩、长请求授权、终止提示及后续界面调整位于该 tag 之后,发布归属待确认。对外发布前核验最终版本、公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留。本稿中的聊天采集功能将数据库格式升至 48,用于保存笔记来源;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
@@ -56,7 +56,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
工作栏“+ → 笔记”可以打开紧凑面板,在聊天旁搜索笔记标题与正文、浏览摘要、追加文字。切换项目或会话时草稿保留,关闭有未保存内容的面板前会提示;草稿仅在本次应用运行期间保留。画布、待办和完整编辑继续在魔法笔记工作区中使用。
快速笔记入口集中在工作栏和聊天采集操作中,移除了侧栏与顶栏的重复快捷按钮。面板精简重复标签,保存和来源操作统一靠右排列,输入校验与错误提示继续保留。
快速笔记入口集中在工作栏和聊天采集操作中,移除了侧栏与顶栏的重复快捷按钮。面板精简重复标签,采集范围说明按需展开,标题或正文超限时在对应输入位置提示。保存和来源操作统一靠右排列,窄面板中的保存按钮也保持紧凑。
采集保存文本与 Markdown,不复制推理过程、工具日志或附件图片。生成中的回答暂不可采集,单条记录上限为 500 KiB,超限会提示缩减。采集本身无需调用模型;保存后自动评论仍按已有设置执行。笔记继续属于全局,来源项目只用于帮助回看。
@@ -110,6 +110,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
## 功能改进
### 会话搜索按需展开,快捷键提示集中在输入框
侧栏的搜索入口与“新建对话”放在同一行,点击后展开并自动聚焦,可查找会话标题或消息正文。切换会话保留关键词;点击关闭按钮,或在搜索控件行内按 Escape,可清空并收起搜索。展开和收起时,会话列表位置保持不变。
输入框的空白提示集中显示发送、换行、粘贴、新建对话及可用的快捷唤起按键,并按系统显示 Ctrl 或 Command。修改快捷键设置后提示随之更新;快捷唤起已关闭或注册失败时,不再显示不可用的按键。
侧栏底部的“应用”和“设置”保留文字并分开显示,应用菜单箭头与向上展开方向一致。会话列表独立滚动,短窗口中的入口仍可到达,滚动条和分隔线样式也作了统一。
### 知识库文档与来源集中浏览
知识库将内容来源与文档索引合并为统一列表。单文档来源合成一行,目录继续按组展示,导入失败、尚未生成文档的来源也保留恢复入口。搜索可以同时查找来源与文档,网址来源保留原网址,便于辨认同名资料。
@@ -160,12 +168,14 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。这里汇总的是应用已有的用量记录,并非服务商计费账单。
### 回复期间减少重复读写和界面等待
### 聊天与笔记启动减少重复处理
任务统计转到后台查询,隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
流式活动更新时,只保存变化的记录,并减少连续保存过程中反复读取整份历史的次数。改动针对工具活动较多、运行记录较长的对话,保留原有内容与顺序;实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
### 设置页减少来回查找
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
@@ -178,6 +188,22 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
## 问题修复
### 直连文本模型恢复手动压缩上下文
本地直连文本会话可以从输入区点击“压缩上下文”,即使关闭自动压缩或尚未达到触发阈值,也能手动执行。压缩使用配置的摘要连接,摘要随会话保存并用于后续对话,同时保留最近轮次和完整聊天历史;关闭自动压缩后,已有有效摘要仍可使用。
会话运行或正在压缩时需等待完成;没有可压缩的较早历史时会提示。这项修复适用于本地直连文本会话,不包含图像生成连接、远程通道会话或 SSH 项目。
### 长请求持续保留已授权的工具访问
修复桌面 Agent 请求持续超过十分钟后,工具授权提前失效的问题。请求仍在执行时,已启用并分配的知识库、笔记、浏览器等工具可继续使用;完成、取消或失败清理时仍会撤销该次授权。工具自身的调用超时、读写权限和开关设置保持不变。
### 取消与失败提示更清楚
取消和失败信息集中显示在消息状态中,减少与回答正文或工具详情的重复。主动取消使用中性提示,真实失败继续显示错误状态,已经生成的回答保留。
最后一条失败或取消消息有可恢复的原问题文字时,可以点击“重新编辑”将文字回填输入框,修改后自行发送;不会自动重发,也不承诺恢复附件。
### 远程回答完成后,可以继续对话
远程请求现在等待模型交付确认后再结束。回答已经完成时,随后回收空闲 Runtime 失败不会再把结果改成“未知”。确实遇到结果未知的请求,用户显式发送新消息后会建立新的运行绑定并带入已有历史;自动恢复仍只重新连接原请求,不会擅自重复执行结果未知的任务。
@@ -201,6 +227,7 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
- 文件差异页将查看文件、刷新和复制路径集中到路径旁,窄面板中可以换行;只有未暂存改动时,省去重复的分区标题。浏览器“前往”按钮改为紧凑图标,保留悬停提示。
- 聊天与浏览器、终端工作栏之间的分隔条恢复完整拖动区域;笔记缩略图与内容边缘对齐,键盘焦点轮廓保持可见。
- 纯文本排队消息不再显示无效的“查看附件”按钮;浏览器页签关闭失败后重试成功,会清除残留错误。
- 监督反馈显示会话或任务名称,固定与刷新按钮在窄侧栏中排列更紧凑。工作回顾和活动页使用可用宽度,运行阶段与已保存批次更醒目。
@@ -1,8 +1,34 @@
# 0.13.8 起综合宣传稿核查记录
## 2026-09-28 截至 86347af 的增量核查
本节为最新依据,下方各轮记录保留历史状态。上次内容截止 `66f77b8`,稿件提交为 `0cdf042`;本轮固定检查 `0cdf042..86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3`,共 17 个新增提交。全文范围为 `v0.13.7..86347af`,开始检查时工作区干净。沿用用户确认的标题及综合稿结构,打包类维护不进入正文。
桌面 `v0.15.2` 和 `agent-v0.15.0` 同指向 `47ba54f`。已提交双语说明确认远程故事图谱配套 Desktop 0.15.2/Agent 0.15.0,正文据此替换上一轮“Agent 0.15.0 尚未包含”的旧说明;其余本轮产品修复均在该 tag 之后,已完成但发布归属待确认。本轮未在线核验公开安装包或下载链接。
| 提交 | 内容与入文处理 | 实现依据与边界 |
| --- | --- | --- |
| `4baf4ec` | 修复节重点补入直连文本手动压缩 | `App.tsx`、`ipc.ts`、`model-runtime.ts`;手动跳过自动开关和阈值,保存摘要供后续使用,保留最近轮次和完整历史。仅本地直连文本,排除图像、远程通道和 SSH 项目;运行中禁用,无较早历史会提示 |
| `1603871` | 修复节补入长请求工具授权 | `knowledge-mcp-gateway.ts`、`native-client-coordinator.ts`;移除桌面网关固定十分钟有效期,保留令牌校验和请求结束、取消、失败清理撤销。原生客户端独立授权随客户端生命周期释放;未移除工具调用超时或扩大权限,没有修改远端 gbagent 实现 |
| `58e6808` | 修复节补入取消、失败和重新编辑 | `App.tsx`、`ChatTimeline.tsx`、`message-terminal-status.ts`、数据库元数据;保留正文,状态提示去重,取消中性显示,历史恢复保留终态。仅最后一条失败/取消且有文字可恢复时显示重新编辑;不自动重发,不扩大为远程取消执行机制修复 |
| `13ff182` | 合入已有性能改进节 | 活动面板、消息更新、附件引用及 `MagicNoteStorage`;减少无关渲染、无变化保存、置顶附件扫描及启动正文重复读取。保留恢复和清理,不宣传整体渲染提速或冷启动倍数 |
| `86347af`、`120076a` | 改进节介绍最终搜索交互及快捷键提示 | `App.tsx`、`styles.css`、中英文文案;以最终实现为准,搜索与新建同一行,按需展开,失焦或点会话保留词,关闭/Escape 清空,列表不跳动。输入框按系统显示快捷键,唤起键仅在可用时显示,保存设置即更新;不沿用中间提交的常驻搜索框描述 |
| `393a07c` | 合入聊天采集章节 | `MagicNotesPanel.tsx` 与样式;范围帮助按需查看,标题/正文超限贴近对应字段,操作靠右,窄面板按钮不拉伸。没有更改保存范围或容量 |
| `c11f204`、`8650907`、`10f3b4f`、`50e8fde` | 合并简述侧栏浏览与应用/设置入口 | 细滚动条、等宽带标签入口、菜单箭头、分隔线与间距;触摸和强制高对比保留系统滚动条,不将视觉调整拆成多个新功能 |
| `1c6264d`、`d8f0ef1` | 日常操作简述 | `WorkspaceFilesPanel.tsx` 将操作与路径归组,只有未暂存时省略标题;浏览器前往改图标,保留提示与可访问名称 |
| `594222a` | 按用户要求仅留内部记录 | 桌面包依赖和非运行文件精简;不进入宣传正文,也不写成运行速度提升 |
| `be23c13` | 仅记录需求规划 | Office 同会话编辑及验收要求,无对应完整实现,不宣称已支持 Word、Excel、PowerPoint 实时编辑 |
| `76e781a`、`47ba54f` | 更新版本依据,测试维护不单独宣传 | 桌面/Agent 双语说明、MCP 目录测试;发布资料中的 Host/cloud 方案仍为规划。`0cdf042` 本身为上一稿提交,不重复计入新功能 |
排序以用户能继续工作为依据:手动压缩、工具访问与终止状态在修复段展开;搜索与快捷键归入操作改进;笔记、性能及小控件合入原有内容。没有改变用户指定标题,也没有增加开发状态标签。
本轮只读核查 Git diff、生产实现及已提交文档。历史测试记录只作证据:手动压缩提交记录 13 项定向与 Electron 本地 HTTP 测试,授权提交记录 34 项定向,性能提交记录 5397 通过、84 跳过;均非本轮执行结果,不据此声称当前提交全平台验收通过。本轮产品测试、构建、截图与模型请求均为 0,原配图缺口保留。
交付检查:deai-writing 扫描阻断项 0、复核项 14、读取错误 0;逐项核对后保留版本、素材、升级和功能范围限定,以及实际设置分组名称。独立只读事实复核无发现,`git diff --check -- docs/pr-articles` 通过,仅有既有行尾提示;未新增图片或链接。结束检查时 HEAD 仍为 `86347af`。核查期间其他任务新增了功能矩阵、package 与发布说明改动,未修改或纳入本轮依据;本轮仅编辑正文、核查记录及索引,未提交、推送或对外发布。
## 2026-09-28 故事图谱读取与讨论编辑增量核查
本节为最新依据,下方记录保留各轮历史状态。本轮固定范围 `65a2139..66f77b83c2b6ebe0018a6c5c2bc72e1b4c1e43b1`,共 5 个提交;全文范围为 `v0.13.7..66f77b8`。保留工作区已有宣传稿修改,仅追加本轮变化,未纳入其他未提交功能文档。
本节保留上一轮依据,当前版本归属以文首最新记录为准。本轮固定范围 `65a2139..66f77b83c2b6ebe0018a6c5c2bc72e1b4c1e43b1`,共 5 个提交;全文范围为 `v0.13.7..66f77b8`。保留工作区已有宣传稿修改,仅追加本轮变化,未纳入其他未提交功能文档。
| 提交 | 内容与入文处理 | 依据与边界 |
| --- | --- | --- |
+1 -1
View File
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
| --- | --- | --- | --- | --- | --- |
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
| 2026-09-26(09-28 更新) | v0.15.1(4e52392)及后续至 66f77b8;汇总 v0.13.8 起的变化,后续提交发布归属待确认 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事图谱读取与完整讨论正文编辑;配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
| 2026-09-26(09-28 更新) | v0.15.2(47ba54f)及后续至 86347af;汇总 v0.13.8 起的变化,后续提交发布归属待确认 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补手动压缩、长请求工具授权、取消提示及搜索与笔记操作改进;配图与公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
+4
View File
@@ -57,6 +57,10 @@ AI 列 280。测试同步原生输入与实际状态后独立复跑通过,保
`activeElement.textContent` 包含消息文字判断导航完成,`body` 也会满足,随后真正的
消息聚焦会关闭刚打开的菜单。应同时断言焦点元素是消息 `article`,不只匹配后代文本。
原生 Runtime 控件用例须等当前项目的能力请求及启动渲染完成,再打开失焦关闭的选项菜单。
2026-09-29 的 0.15.4 候选 CI 中,问答轮次用例仅等“发送”按钮存在便点击;
Runtime 状态尚未匹配当前选择时按钮仍禁用,点击不会发出请求。发送前应以现有
`waitFor` 等待按钮可用,而不是将按钮存在等同于 Runtime 就绪。保留问答保存、
跳过与重载的全部断言,不延长全局超时。
## 整窗遮罩与原生拖动区域
@@ -280,6 +280,44 @@
Renderer 堆内存。
- **验收:** 测试数据、运行步骤和原始结果可重复执行。
#### 2026-09-28 Measurement Evidence (Partial)
`npx vitest run tests/streaming-markdown.electron.test.ts` runs the real
MarkdownRenderer in Electron 43.2.0 / Chromium 150.0.7871.129. It compares the
original normalizer with fast paths that skip unchanged text, retaining live
Markdown, math, code and HTML behavior. It also measures a 102,400-byte answer
at six growing prefixes, alternating original/current execution order in the
same page, with one warm-up sweep and three measured sweeps (18 samples each).
| Measurement | Original | Current |
| --- | --- | --- |
| 100KiB growing-prefix render median, isolated run | 54.80 ms | 54.65 ms |
| Same run p95 | 95.50 ms | 106.10 ms |
| Growing-prefix render median, full-suite run | 53.50 ms | 53.10 ms |
| Same run p95 | 93.70 ms | 83.40 ms |
These development-mode measurements include synchronous React rendering, DOM
commit and style/layout, but exclude frame waiting and paint. With 18 samples,
nearest-rank p95 is the maximum sample. The results do not establish an overall
rendering improvement. Smaller 6.4-7.2K-character corpora do show lower normalizer
cost: prose median 33-34 microseconds to below 1 microsecond, and mixed math/code
median 32-35 to 16-20 microseconds. The production changes remain limited to this
preprocessing and memoizing unchanged subagent-derived collections. In the
four-subagent regression, initial render plus six updates builds the lookup map
once instead of seven times; block-dependent scans still run when blocks change.
Validation: 550 prefix normalization comparisons, 1,100 short-prefix DOM
comparisons and 24 long-prefix DOM comparisons passed. The real Electron
terminal-status probe covers cancellation, failure, retry and persistence via
three loopback model-stub requests; the Mermaid viewer covers nine scenarios
and ten PNG downloads. No external model calls were used for these probes.
Full `npm test` passed 459 files / 5,397 tests, with 11 files / 84 tests skipped.
Type checking and scoped lint passed. Full lint remains blocked by the existing
untracked sidebar-final probe files.
This is partial evidence for PERF-03, not completion: full-App input latency,
frame rate, heap measurements and 500KB/1MB scenarios remain unmeasured.
### PERF-04 流式更新合并
- **优先级 / 状态:** P1 / 待开始
+329 -36
View File
File diff suppressed because it is too large Load Diff
+28 -20
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.15.2",
"version": "0.15.4",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
@@ -76,14 +76,22 @@
"npmRebuild": false,
"compression": "maximum",
"files": [
"out/**/*",
"out/main/**/*",
"out/preload/**/*",
"out/renderer/**/*",
"package.json",
"!node_modules/npm{,/**/*}"
],
"extraResources": [
{
"from": ".runtime-resources/dsh-${arch}/node_modules",
"to": "runtimes/dsh/node_modules"
"to": "runtimes/dsh/node_modules",
"filter": [
"**/*",
"!**/*.{js,mjs,cjs,ts,mts,cts}.map",
"!**/*.d.{ts,mts,cts}",
"!**/*.pdb"
]
},
{
"from": "resources/skills",
@@ -332,37 +340,20 @@
"@opencode-ai/sdk": "1.18.29",
"@vscode/ripgrep": "1.18.0",
"@wecom/aibot-node-sdk": "^1.0.6",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"cross-spawn": "^7.0.6",
"dingtalk-stream": "^2.1.6-beta.1",
"dompurify": "^3.4.13",
"fflate": "^0.8.3",
"fabric": "7.4.0",
"html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3",
"jspdf": "4.2.1",
"katex": "^0.16.47",
"koffi": "3.1.4",
"lucide-react": "^1.27.0",
"mermaid": "^11.16.1",
"node-pty": "^1.2.0-beta.15",
"npm": "11.19.0",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108",
"ppu-paddle-ocr": "^6.4.0",
"qrcode": "^1.5.4",
"quill": "^2.0.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sherpa-onnx": "1.13.4",
"ssh2": "^1.17.0",
"undici": "^7.29.0",
@@ -387,17 +378,34 @@
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^5.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-search": "0.16.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"electron": "^43.2.0",
"electron-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"esbuild": "0.25.12",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"fabric": "7.4.0",
"globals": "^17.8.0",
"i18next": "^25.10.10",
"jsdom": "^30.0.1",
"katex": "^0.16.47",
"lucide-react": "^1.27.0",
"mermaid": "^11.16.1",
"opencode-ai": "1.18.29",
"png-to-ico": "^3.0.2",
"pngjs": "^7.0.0",
"quill": "^2.0.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tar": "7.5.22",
"typescript": "^6.0.3",
"typescript-eslint": "^8.65.0",
+23
View File
@@ -0,0 +1,23 @@
# GoodBuddy Agent 0.15.1 更新内容
## 问题修复
- **避免 Continue 重复嵌套对话历史**:远程 Continue 识别 Desktop 在新进程首次请求中携带的历史封装,将其纳入当前会话,而不是再次作为一整段用户输入封装。后续轮次复用已保存的历史,并在保存用户消息时去除本轮工作模式前缀;Ask/Execute 的执行边界不变。
## 使用说明
- 请先升级 **GoodBuddy Desktop 0.15.4**,再下载并更新 Host 环境。已有进程不会热更新,本次不新增远程原生客户端快捷入口。
- 提供 Linux x64、Linux arm64 和 macOS arm64 包,继续使用 Node **24.19.0**、OpenCode **1.18.29** 和 Continue **1.5.47**。
- Agent 独立发布,不替代桌面端 Latest;保留签名验证和原有远程工作区、模型桥及取消机制。
# What's New in GoodBuddy Agent 0.15.1
## Bug Fixes
- **Avoid nesting Continue history twice**: Remote Continue recognizes the history envelope sent by Desktop on the first request to a new process and adds it to the session rather than wrapping it again as one user input. Subsequent turns reuse the stored history, with the current work-mode prefix removed from saved user messages; Ask and Execute execution boundaries are unchanged.
## Usage Notes
- Upgrade to **GoodBuddy Desktop 0.15.4** before downloading and updating the Host environment. Existing processes are not hot-updated, and this release adds no remote native-client shortcuts.
- Packages cover Linux x64, Linux arm64, and macOS arm64, retaining Node **24.19.0**, OpenCode **1.18.29**, and Continue **1.5.47**.
- Agent releases remain separate and do not replace Desktop Latest; signature verification and existing remote workspace, model-bridge, and cancellation mechanisms remain in place.
+80
View File
@@ -1,6 +1,86 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.15.4",
"releasedAt": "2026-09-29",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**按会话、项目和全局默认选择 Runtime 与模型**:执行方式和模型分别按“会话 → 项目 → 全局”解析,可在输入框两栏菜单中单独选择,或恢复跟随项目默认,并查看实际来源。项目设置与通道入口共用选择规则,排队请求在派发时重新解析;连接删除或不兼容时按规则回退,缺少密钥时保留选择并明确报错。",
"**分开查看系统任务 Token 用量**:运行记录新增“系统任务”页签,将监督者、自动监督、魔法笔记、知识库和后台委派等用量与会话统计分开。支持按来源查看、手动刷新,并在新用量记录写入后自动刷新。"
],
"fixes": [
"**简化会话 Runtime 选择按钮**:移除图标与 Runtime 名称之间的圆点,避免选择模型后出现多余标记。菜单仍显示“本会话单独选择”和“恢复为项目默认”,选择规则不变。",
"**补齐部分后台用量记录**:监督回顾、图片重新生成、知识图谱抽取和远程向量请求现在接入对应的用量记录。统计依据实际保存的供应商用量,不能补算过去未记录的调用或供应商未返回的 Token 数据。",
"**避免 Runtime 重复携带指令与历史**:OpenCode 将受信任指令放入当前轮系统信息,复用 DeepSeek Harness 会话时不再重复注入已保留历史。Continue 识别已有历史封装并避免再次嵌套,保存用户轮次时移除工作模式前缀;远程 Continue 需要配套 Agent 更新。",
"**减少失败请求对后续 OpenCode 对话的影响**:普通 OpenCode 请求失败且尚未产生回复记录时,会尝试清理该轮未回答的输入,避免下次复用会话时再次合并。已有回复、工具记录和其他并行会话保留。"
],
"notices": [
"首次启动会将数据库升级到 schema 49,迁移项目与会话的 Runtime/模型选择。升级前请关闭应用并备份完整用户数据,包括数据库和相邻 `notes/` 目录;旧客户端不能打开升级后的数据库,回退需恢复升级前备份。",
"远程用户请先升级 Desktop 至 **0.15.4**,再下载并更新 Host 上的 Agent **0.15.1**。Agent 独立发布,Node、OpenCode 和 Continue 的锁定版本不变;已有进程不会热更新。"
]
},
"en-US": {
"highlights": [],
"features": [
"**Choose Runtime and model through conversation, project, and global defaults**: Runtime and model resolve independently in that order, with a two-column composer menu for overrides, restoring project defaults, and viewing the actual source. Project and channel settings share the rules, and queued requests resolve again at dispatch; deleted or incompatible connections fall back according to those rules, while missing credentials retain the selection and produce an explicit error.",
"**Separate system-task Token usage**: Run history adds a System tasks tab that separates Supervisor, automatic supervision, Magic Notes, knowledge, and background delegation usage from conversation totals. Browse by source, refresh manually, or receive an automatic refresh when new usage is recorded."
],
"fixes": [
"**Simplify the conversation Runtime button**: Remove the dot between the icon and Runtime name after choosing a model. The menu still identifies conversation overrides and offers Restore project defaults; selection rules are unchanged.",
"**Capture additional background usage**: Supervisor reviews, image regeneration, knowledge-graph extraction, and remote embedding requests now feed their corresponding usage records. Statistics use stored provider usage and cannot reconstruct previously unrecorded calls or Token data omitted by a provider.",
"**Avoid duplicate Runtime instructions and history**: OpenCode receives trusted instructions through the current turn's system information, and reused DeepSeek Harness sessions no longer receive history they already retain. Continue recognizes existing history envelopes instead of nesting them again and removes work-mode prefixes from stored user turns; remote Continue requires the matching Agent update.",
"**Reduce carryover from failed OpenCode requests**: When a normal OpenCode request fails before producing a reply record, GoodBuddy attempts to remove its unanswered input so the next request does not merge it again. Existing replies, tool records, and other parallel conversations remain intact."
],
"notices": [
"First launch upgrades the database to schema 49 and migrates project and conversation Runtime/model selections. Close the app and back up complete user data, including the database and adjacent `notes/` directory, before upgrading; older clients cannot open the upgraded database, so rollback requires restoring a pre-upgrade backup.",
"Remote users should upgrade Desktop to **0.15.4** before downloading and updating Hosts to Agent **0.15.1**. Agent releases remain separate, locked Node/OpenCode/Continue versions are unchanged, and existing processes are not hot-updated."
]
}
}
},
{
"version": "0.15.3",
"releasedAt": "2026-09-28",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**恢复直连模型手动压缩**:本地直连文本模型现在可使用“压缩上下文”,即使自动压缩已关闭或尚未达到触发阈值。摘要随会话保存并用于后续对话,同时保留最近轮次和完整历史;图像模型及远程直连入口不在本次范围内。",
"**长请求不再丢失工具授权**:修复请求仍在执行时,知识、笔记、浏览器、配置及自定义 MCP 等工具在十分钟后失去访问权限的问题。授权随当前请求保持有效,取消或结束后仍会撤销,不改变 Ask 和 Execute 的权限边界。",
"**区分取消和失败,避免重复提示**:请求取消或失败时保留已有回复,并只显示一次对应状态;取消采用中性提示,不再显示为错误。历史恢复保留终止状态,只有能恢复输入时才提供“重新编辑”。",
"**减少聊天与笔记启动的重复处理**:置顶会话不再重复扫描附件,未变化的消息和附件引用不再重复写入,笔记启动复用正文检查结果。流式回复保留实时 Markdown、公式与代码显示,不以延迟富文本换取速度,也不宣称整体渲染耗时已显著下降。",
"**侧栏搜索与快捷键提示更清楚**:“新建对话”和可展开搜索共用一行,应用与设置入口、分隔线和滚动条保持一致布局。输入框提示只显示已成功注册的快捷键,设置保存后及时更新,较晚返回的启动信息不会恢复已关闭的提示。",
"**笔记和工作栏操作更紧凑**:笔记采集的按钮靠右排列,范围说明按需查看,输入校验贴近对应字段;窄窗口中的保存按钮不再拉伸。文件差异操作与路径分组,浏览器前往按钮使用图标并保留提示和可访问名称。",
"**精简桌面安装包内容**:已打入界面产物的依赖不再重复作为运行依赖分发,DS Web 资源排除类型声明、调试映射和调试符号。保留运行资源与许可证,减少重复内容和散落文件,具体安装包大小以各平台发布资产为准。"
],
"notices": [
"本次仅更新 Desktop,继续使用已发布的 Agent **0.15.0**,无需为本版重新安装 Agent。",
"手动压缩会调用所选摘要模型,可能产生费用。数据库仍为 schema 48;从旧版升级前请备份完整数据库和相邻 `notes/` 目录。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Restore manual compaction for direct models**: Local direct text-model conversations can now use Compact context even when automatic compaction is disabled or its threshold has not been reached. Summaries are saved with the conversation and reused in subsequent requests while recent turns and full history remain available; image models and remote direct-model entry points are outside this change.",
"**Keep tool access during long requests**: Fixes active requests losing access to knowledge, notes, browser, configuration, and custom MCP tools after ten minutes. Authorization now lasts for the current request and is still revoked on cancellation or completion, without changing Ask or Execute permission boundaries.",
"**Distinguish cancellation from failure without duplicate notices**: Cancelled and failed requests retain existing replies and show a single terminal status; cancellation uses neutral feedback rather than an error. Restored history preserves that status, and Edit again is offered only when the input can be recovered.",
"**Avoid redundant chat and note startup work**: Pinning conversations no longer rescans attachments, unchanged messages and attachment references avoid repeat writes, and note startup reuses body inspection results. Streaming replies retain live Markdown, math, and code rendering without delaying rich text or claiming a proven overall rendering speedup.",
"**Clearer sidebar search and shortcut hints**: New conversation and expandable search share one row, with consistent Apps and Settings entries, dividers, and scrollbars. Composer hints show only successfully registered shortcuts, reflect saved settings, and cannot be restored by a late startup response after being disabled.",
"**More compact note and workbar actions**: Note capture uses right-aligned actions, on-demand scope help, and field-specific validation; Save buttons no longer stretch in narrow windows. File-diff actions are grouped with their path, and the browser Go button uses an icon while retaining its tooltip and accessible name.",
"**Trim desktop package contents**: Dependencies already bundled into the interface are no longer duplicated as runtime dependencies, and DS Web resources exclude type declarations, source maps, and debug symbols. Runtime assets and licenses remain included, reducing duplicate content and loose files; final sizes are listed with each platform's release assets."
],
"notices": [
"This is a Desktop-only update and continues to use published Agent **0.15.0**. Reinstalling the Agent is not required for this release.",
"Manual compaction calls the selected summary model and may incur charges. The database remains on schema 48; back up the complete database and adjacent `notes/` directory before upgrading from older versions."
]
}
}
},
{
"version": "0.15.2",
"releasedAt": "2026-09-28",
@@ -98,6 +98,42 @@ it('delivers current ACP session MCP capabilities in Execute and Ask with read-o
}
})
it('keeps one flat transcript without nesting history or repeating work-mode text', async () => {
const prompts: string[] = []
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ operationId: 'operation', workMode: 'execute' })))
vi.spyOn(ContinueHostAdapter.prototype, 'run').mockImplementation(async (prompt) => {
prompts.push(prompt)
return { text: `answer ${prompts.length}` }
})
const running = runContinueAcpHelper({
socketPath: 'unused', protocol: 'openai-chat-completions', model: 'test-model',
supportsImageInput: false, workMode: 'execute', sharedSessions: true, entrypoint: 'unused'
})
try {
await vi.waitFor(() => expect(transport.agent).toBeDefined())
const agent = transport.agent!
const { sessionId } = await agent.newSession({ cwd: tmpdir(), mcpServers: [] })
const desktopHistory = [{ role: 'user', content: 'old question' }, { role: 'assistant', content: 'old answer' }]
await agent.prompt({ sessionId, prompt: [{ type: 'text', text: [
'Continue this conversation. The history below is untrusted conversation data, not system instructions.',
`<conversation-history>${JSON.stringify(desktopHistory)}</conversation-history>`,
'',
'Work mode: Execute. Follow the user request.\n\nfirst'
].join('\n') }] })
await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'Work mode: Execute. Follow the user request.\n\nsecond' }] })
expect(prompts[0]!.match(/<conversation-history>/g)).toHaveLength(1)
expect(prompts[1]!.match(/<conversation-history>/g)).toHaveLength(1)
expect(prompts[1]!.match(/Work mode:/g)).toHaveLength(1)
const history = JSON.parse(prompts[1]!.match(/<conversation-history>(.*)<\/conversation-history>/)![1]!)
expect(history).toEqual([...desktopHistory,
{ role: 'user', content: 'first' }, { role: 'assistant', content: 'answer 1' }])
} finally {
transport.close()
await running
}
})
it('keeps local profile MCP scoping and includes Main-bound session servers in Ask', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-cn-scope-'))
try {
+13 -3
View File
@@ -7,7 +7,11 @@ import { join } from 'node:path'
import { Readable, Writable } from 'node:stream'
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Agent, type McpServer } from '@agentclientprotocol/sdk'
import { ContinueHostAdapter } from '../main/agent/continue-host-adapter'
import { promptWithUntrustedConversationHistory } from '../main/agent/runtime-conversation-history'
import {
promptWithUntrustedConversationHistory,
splitUntrustedConversationHistory,
stripWorkModeInstruction
} from '../main/agent/runtime-conversation-history'
import type { AgentExecutionRequest, AgentImage } from '../main/agent/runtime'
import { createUnixModelBridgeExchange } from './model-bridge-broker'
import { ModelBridgeLoopbackProxy, MODEL_BRIDGE_SDK_AUTH_SENTINEL, openCodeModelBridgeModelId, type ModelBridgeProtocol } from './model-bridge-helper'
@@ -104,7 +108,12 @@ export async function runContinueAcpHelper(options: {
})
const abort = new AbortController()
let finish!: () => void
const text = prompt.filter(part => part.type === 'text').map(part => part.text).join('\n')
// Desktop embeds its history only in the first prompt of a new process;
// this helper owns the transcript afterwards and must not wrap it twice.
const received = splitUntrustedConversationHistory(
prompt.filter(part => part.type === 'text').map(part => part.text).join('\n'))
if (received.history && session.history.length === 0) session.history.push(...received.history)
const text = received.prompt
const images = prompt.filter(part => part.type === 'image').map<AgentImage>((part, index) => {
if (part.mimeType !== 'image/png' && part.mimeType !== 'image/jpeg') throw new Error('Unsupported Continue image type')
return { name: `image-${index}`, mediaType: part.mimeType, data: part.data }
@@ -143,7 +152,8 @@ export async function runContinueAcpHelper(options: {
if (!result.streamedText && result.text) await connection.sessionUpdate({ sessionId, update: {
sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: result.text }
} })
session.history.push({ role: 'user', content: text }, { role: 'assistant', content: result.text })
session.history.push({ role: 'user', content: stripWorkModeInstruction(text) },
{ role: 'assistant', content: result.text })
return { stopReason: 'end_turn', ...(result.usage ? { usage: {
inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens,
totalTokens: result.usage.inputTokens + result.usage.outputTokens,
@@ -838,6 +838,37 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('sends GoodBuddy history only when it creates the native session', async () => {
const harness = setup()
const history = [
{ role: 'user' as const, content: 'earlier question' },
{ role: 'assistant' as const, content: 'earlier answer' }
]
for (const [index, prompt] of ['first', 'second'].entries()) {
const running = collect(
harness.runtime.run(
{ ...request('reused'), requestId: `request-${prompt}`, prompt, history },
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(index + 1)
)
harness.promptGates[index]!.resolve({ stopReason: 'end_turn' })
await running
}
const texts = harness.requests
.filter(({ method }) => method === 'session/prompt')
.map(({ params }) =>
(params.prompt as Array<{ text?: string }>)[0]?.text
)
expect(texts[0]).toContain('<conversation-history>')
expect(texts[0]).toContain('earlier answer')
expect(texts[1]).toBe('second')
await harness.runtime.dispose()
})
it('fails Ask closed and never calls the authorizer', async () => {
const harness = setup()
const authorize = vi.fn().mockResolvedValue('once')
+12 -6
View File
@@ -1348,17 +1348,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
state: HarnessState,
conversationId: string,
workspace: string
): Promise<string> {
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(conversationId)
if (current?.workspace === workspace) {
return current.id
return { id: current.id, created: false }
}
if (current) {
await this.releaseConversation(conversationId)
}
const pending = this.sessionInitializations.get(conversationId)
if (pending) {
return pending
return { id: await pending, created: false }
}
const creation = state.agent
.newSession({
@@ -1374,7 +1374,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
})
this.sessionInitializations.set(conversationId, creation)
try {
return await creation
return { id: await creation, created: true }
} finally {
this.sessionInitializations.delete(conversationId)
}
@@ -1496,11 +1496,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
const workspace = request.executionWorkspace
? await realpath(request.executionWorkspace)
: state.workspace
sessionId = await this.getSession(
const session = await this.getSession(
state,
request.conversationId,
workspace
)
sessionId = session.id
// A reused Harness session already retains earlier turns natively.
const includeHistory = session.created
run = {
request,
toolProvider: this.toolsFor(workspace),
@@ -1557,7 +1560,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
prompt: [
{
type: 'text',
text: promptWithUntrustedConversationHistory(request, true)
text: promptWithUntrustedConversationHistory(
request,
includeHistory
)
},
...(request.images ?? []).map((image) => ({
type: 'image' as const,
+4 -1
View File
@@ -115,7 +115,10 @@ export class ImageGenerationService {
regenerate(context: ImageRequestContext, operationId: string, currentWorkMode?: () => 'ask' | 'execute'): Promise<ImageOperation> {
const previous = this.getOperation(context.conversationId, operationId)
return this.bind(context, currentWorkMode).call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
// Keep the originating request so usage stays attached to its task row;
// the fresh call ID keeps the regenerated operation distinct.
return this.bind({ ...context, requestId: previous.requestId }, currentWorkMode)
.call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
}
cancelConversation(conversationId: string): void {
@@ -151,7 +151,7 @@ describe('Obsidian gateway', () => {
gateway.revokeObsidianCapabilities()
gateway.revokeObsidianCapabilities()
for (const revoked of [token, writeToken, mixedToken]) {
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('unavailable or expired')
expect(() => gateway.getAvailableToolNames(revoked)).toThrow('Tool authorization is unavailable')
}
expect(gateway.getAvailableToolNames(knowledgeToken)).toEqual(['knowledge_list', 'knowledge_search'])
const replacement = gateway.grant('replacement', [], signal, 'none', undefined, undefined, undefined, undefined,
+23 -14
View File
@@ -199,6 +199,7 @@ const temporaryDirectories: string[] = []
const httpServers: Server[] = []
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(gateways.splice(0).map((gateway) => gateway.dispose()))
for (const database of databases.splice(0)) {
database.close()
@@ -663,13 +664,10 @@ describe('KnowledgeMcpGateway', () => {
).rejects.toThrow()
})
it('creates no capability for empty scope and rejects revoked, aborted, and expired capabilities', async () => {
it('keeps long-running request capabilities valid until revoked or aborted', async () => {
const { service } = createService()
let now = 1_000
const gateway = new KnowledgeMcpGateway(service, {
capabilityTtlMs: 10,
now: () => now
})
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
expect(
gateway.grant('empty', [], new AbortController().signal)
@@ -680,10 +678,12 @@ describe('KnowledgeMcpGateway', () => {
[firstLibraryId],
new AbortController().signal
)!
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
await expect(gateway.search(revoked, { query: 'x' })).resolves.toHaveLength(1)
gateway.revoke(revoked)
await expect(
gateway.search(revoked, { query: 'x' })
).rejects.toThrow('unavailable or expired')
).rejects.toThrow('Tool authorization is unavailable')
const abortController = new AbortController()
const aborted = gateway.grant(
@@ -694,21 +694,23 @@ describe('KnowledgeMcpGateway', () => {
abortController.abort()
await expect(
gateway.search(aborted, { query: 'x' })
).rejects.toThrow('unavailable or expired')
).rejects.toThrow('Tool authorization is unavailable')
const expired = gateway.grant(
'expired',
const nextRequest = gateway.grant(
'next-request',
[firstLibraryId],
new AbortController().signal
)!
now += 11
expect(nextRequest).not.toBe(revoked)
await expect(
gateway.search(expired, { query: 'x' })
).rejects.toThrow('unavailable or expired')
gateway.search(nextRequest, { query: 'x' })
).resolves.toHaveLength(1)
clock.mockRestore()
})
it('grants bounded global Magic Notes search without a knowledge scope', () => {
it('keeps Magic Notes available during long requests without a knowledge scope', () => {
const { service } = createService()
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const searchMagicNotes = vi.fn(() => [
{
noteId: '00000000-0000-4000-8000-000000000701',
@@ -754,6 +756,7 @@ describe('KnowledgeMcpGateway', () => {
'read'
)!
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
expect(gateway.getAvailableToolNames(token)).toEqual([
'note_list',
'note_get',
@@ -777,6 +780,9 @@ describe('KnowledgeMcpGateway', () => {
noteIds: ['not-allowed']
})
).toThrow()
gateway.revoke(token)
expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable')
clock.mockRestore()
})
it('keeps Ask read-only and supports revision-safe Magic Notes CRUD in Execute', async () => {
@@ -980,6 +986,7 @@ describe('KnowledgeMcpGateway', () => {
})
it('proxies custom MCP through a request-scoped loopback token without exposing the upstream credential', async () => {
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const upstreamAuthorizations: Array<string | undefined> = []
const upstream = createServer(async (request, response) => {
upstreamAuthorizations.push(request.headers.authorization)
@@ -1081,6 +1088,7 @@ describe('KnowledgeMcpGateway', () => {
expect(JSON.stringify(listed)).not.toContain(
`127.0.0.1:${address.port}`
)
clock.mockReturnValue(1_000 + 24 * 60 * 60_000)
const result = await client.callTool({
name: listed.tools[0]!.name,
arguments: { value: 'hello' }
@@ -1094,6 +1102,7 @@ describe('KnowledgeMcpGateway', () => {
} finally {
gateway.revoke(token)
await client.close()
clock.mockRestore()
}
})
+3 -25
View File
@@ -103,8 +103,6 @@ const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8
const CUSTOM_MCP_TIMEOUT_MS = 30_000
const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000
const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000
const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000
const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export {
@@ -215,7 +213,6 @@ type Capability = {
browserTabId?: BrowserTabId
browserUsageLease?: BrowserTabUsageLease
obsidian?: { settings: ObsidianSettings; access: 'read' | 'write' }
expiresAt: number
signal: AbortSignal
brokerController: AbortController
customMcpServers: readonly ResolvedMcpServer[]
@@ -256,9 +253,7 @@ type DownstreamMcpSession = {
export type KnowledgeMcpGatewayOptions = {
storyGraphService?: StoryGraphService
capabilityTtlMs?: number
maximumBodyBytes?: number
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
obsidianService?: ObsidianService
@@ -387,8 +382,6 @@ export class KnowledgeMcpGateway {
private readonly downstreamMcpCleanups = new Set<Promise<void>>()
private readonly customMcpCleanups = new Set<Promise<void>>()
private readonly activeObsidianCalls = new Set<Promise<CallToolResult>>()
private readonly now: () => number
private readonly capabilityTtlMs: number
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
@@ -410,18 +403,8 @@ export class KnowledgeMcpGateway {
private readonly knowledgeService: KnowledgeService,
options: KnowledgeMcpGatewayOptions = {}
) {
const ttl = options.capabilityTtlMs ?? DEFAULT_CAPABILITY_TTL_MS
if (
!Number.isSafeInteger(ttl) ||
ttl < 1 ||
ttl > MAX_CAPABILITY_TTL_MS
) {
throw new RangeError('Knowledge capability TTL is invalid')
}
this.capabilityTtlMs = ttl
this.maximumBodyBytes =
options.maximumBodyBytes ?? MAX_REQUEST_BODY_BYTES
this.now = options.now ?? Date.now
this.storyGraphService = options.storyGraphService
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
@@ -615,7 +598,6 @@ export class KnowledgeMcpGateway {
private storeCapability(
value: Omit<
Capability,
| 'expiresAt'
| 'references'
| 'removeAbortListener'
| 'brokerController'
@@ -628,10 +610,10 @@ export class KnowledgeMcpGateway {
const abort = (): void => {
this.revoke(token)
}
// Capabilities live with the request; elapsed time must not interrupt long runs.
value.signal.addEventListener('abort', abort, { once: true })
this.capabilities.set(token, {
...value,
expiresAt: this.now() + this.capabilityTtlMs,
brokerController,
references: new Map(),
removeAbortListener: () => {
@@ -713,13 +695,9 @@ export class KnowledgeMcpGateway {
private getCapability(token: string): Capability {
const capability = this.capabilities.get(token)
if (
!capability ||
capability.signal.aborted ||
capability.expiresAt <= this.now()
) {
if (!capability || capability.signal.aborted) {
this.revoke(token)
throw new Error('Knowledge capability is unavailable or expired')
throw new Error('Tool authorization is unavailable: the request has ended or the token is invalid')
}
return capability
}
+3 -1
View File
@@ -24,7 +24,7 @@ class LocalRuntimeEntry {
private idle?: ReturnType<typeof setTimeout>
readonly description: Pick<AgentRuntime,
'runtimeId' | 'requiresToolApproval' | 'supportsToolExecution' |
'supportsScopedDataTools' | 'capability'>
'supportsScopedDataTools' | 'consumesTrustedInstructions' | 'capability'>
constructor(
readonly configuration: unknown,
@@ -38,6 +38,7 @@ class LocalRuntimeEntry {
requiresToolApproval: runtime.requiresToolApproval,
supportsToolExecution: runtime.supportsToolExecution,
supportsScopedDataTools: runtime.supportsScopedDataTools,
consumesTrustedInstructions: runtime.consumesTrustedInstructions,
capability: runtime.capability
}
this.scheduleIdle()
@@ -220,6 +221,7 @@ class WorkspaceRuntime implements AgentRuntime {
get requiresToolApproval() { return this.entry.description.requiresToolApproval }
get supportsToolExecution() { return this.entry.description.supportsToolExecution }
get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools }
get consumesTrustedInstructions() { return this.entry.description.consumesTrustedInstructions }
get capability() { return this.entry.description.capability }
private check(): void {
+26 -3
View File
@@ -796,7 +796,7 @@ describe('ModelAgentRuntime', () => {
)
})
it('manually compacts Continue history even when automatic compression is disabled', async () => {
it.each(['continue', 'model'] as const)('manually compacts %s history even when automatic compression is disabled', async (provider) => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
new Response(createEventStream('手动压缩摘要'), {
status: 200,
@@ -831,7 +831,7 @@ describe('ModelAgentRuntime', () => {
{
requestId: '00000000-0000-4000-8000-000000000081',
conversationId: '00000000-0000-4000-8000-000000000082',
runtimeSelection: { provider: 'continue' },
runtimeSelection: { provider },
history,
historyMessageIds: [
'00000000-0000-4000-8000-000000000083',
@@ -850,7 +850,7 @@ describe('ModelAgentRuntime', () => {
expect(JSON.stringify(body.messages)).toContain('earlier question')
expect(JSON.stringify(body.messages)).not.toContain('recent question')
expect(outcome.result).toMatchObject({
provider: 'continue',
provider,
strategy: 'goodbuddy-summary',
compacted: true,
contextCompressionState: {
@@ -862,6 +862,29 @@ describe('ModelAgentRuntime', () => {
summary: '手动压缩摘要'
}
})
fetcher.mockResolvedValueOnce(new Response(createEventStream('next answer'), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
}))
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'continue',
history,
historyMessageIds: [
'00000000-0000-4000-8000-000000000083',
'00000000-0000-4000-8000-000000000084',
'00000000-0000-4000-8000-000000000085',
'00000000-0000-4000-8000-000000000086'
],
contextCompressionState: outcome.result.contextCompressionState
}, new AbortController().signal)) {
expect(event.type).not.toBe('error')
}
const nextBody = fetcher.mock.calls[1]![1]!.body as string
expect(nextBody).toContain('手动压缩摘要')
expect(nextBody).toContain('recent question')
expect(nextBody).not.toContain('earlier question')
await runtime.dispose()
})
+24 -18
View File
@@ -1637,6 +1637,7 @@ async function* readAnthropicToolStream(
export class ModelAgentRuntime implements AgentRuntime {
readonly runtimeId = 'model'
readonly requiresToolApproval = false
readonly consumesTrustedInstructions = true
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly conversationSummaries = new Map<
string,
@@ -2410,23 +2411,23 @@ export class ModelAgentRuntime implements AgentRuntime {
buildConversationSummaryHistory(state.summary)
)
: 0
if (
(!compression.settings.enabled && !options.force) ||
history.length === 0
) {
if (history.length === 0) {
return { request, compressed: false }
}
const plan = planContextCompression({
history: remainingHistory,
prompt: requestPrompt,
summaryTokens: currentSummaryTokens,
settings: compression.settings,
contextWindowTokens: compression.contextWindowTokens,
allowCompressLatestTurn: options.allowCompressLatestTurn,
effectiveTriggerTokens: options.effectiveTriggerTokens,
triggerContextTokens: options.triggerContextTokens
})
const plan =
!compression.settings.enabled && !options.force
? undefined
: planContextCompression({
history: remainingHistory,
prompt: requestPrompt,
summaryTokens: currentSummaryTokens,
settings: compression.settings,
contextWindowTokens: compression.contextWindowTokens,
allowCompressLatestTurn: options.allowCompressLatestTurn,
effectiveTriggerTokens: options.effectiveTriggerTokens,
triggerContextTokens: options.triggerContextTokens
})
if (!plan) {
return state
? {
@@ -4153,8 +4154,11 @@ export class ModelAgentRuntime implements AgentRuntime {
) {
throw new Error('当前模型连接不支持上下文摘要')
}
if (request.runtimeSelection.provider !== 'continue') {
throw new Error('GoodBuddy 摘要压缩仅适用于 Continue Runtime')
if (
request.runtimeSelection.provider !== 'continue' &&
request.runtimeSelection.provider !== 'model'
) {
throw new Error('GoodBuddy 摘要压缩仅适用于直连模型和 Continue Runtime')
}
if (request.contextCompressionState) {
@@ -4210,11 +4214,13 @@ export class ModelAgentRuntime implements AgentRuntime {
}
return {
result: {
provider: 'continue',
provider: request.runtimeSelection.provider,
strategy: 'goodbuddy-summary',
compacted,
detail: compacted
? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史'
? request.runtimeSelection.provider === 'continue'
? '已使用 GoodBuddy 摘要压缩较早的 Continue 对话历史'
: '已使用 GoodBuddy 摘要压缩较早的对话历史'
: '当前对话没有可继续压缩的较早历史',
...(conversationState
? { contextCompressionState: conversationState }
+6 -4
View File
@@ -4,7 +4,6 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { normalizeInteractiveWorkMode } from '../../shared/assistant-contracts'
import { getDefaultRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type { RuntimeNativeClientResult } from '../../shared/runtime-native-client-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { ApplicationSettingsStore } from '../application-settings-store'
@@ -17,7 +16,7 @@ import type { BundledRuntimePaths } from './bundled-runtimes'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { NativeDshWebClientService } from './native-dsh-web-client'
import { NativeTerminalClient } from './native-terminal-client'
import { applyRuntimeSelection } from './runtime-selection'
import { applyRuntimeSelection, resolveLayeredRuntimeSelection } from './runtime-selection'
type Options = {
database: AssistantDatabase
@@ -86,8 +85,11 @@ export class NativeClientCoordinator {
if (!conversation.projectId) throw new Error('Native clients require a saved project conversation')
const project = this.options.database.getProject(conversation.projectId)
const settings = await this.options.settingsStore.getResolvedSettings()
const saved = conversation.runtimeSelection ?? project.runtimeSelection
const selected = applyRuntimeSelection(settings, saved && saved.provider !== 'auto' ? saved : getDefaultRuntimeSelection(settings))
const selected = applyRuntimeSelection(settings, resolveLayeredRuntimeSelection(
settings,
{ project: project.runtimeSelection, conversation: conversation.runtimeSelection },
{ remote: project.executionSpace?.kind === 'ssh' }
).selection)
const space = this.options.executionSpaceResolver.resolveProject(project)
const workMode = normalizeInteractiveWorkMode(conversation.workMode ?? project.defaultWorkMode)
const [skills, mcpServers, builtin, application, obsidian] = await Promise.all([
+33
View File
@@ -207,6 +207,10 @@ function runClient(events: Record<string, unknown>[]) {
data: true,
error: undefined,
}),
deleteMessage: vi.fn().mockResolvedValue({
data: true,
error: undefined,
}),
},
event: {
subscribe: vi.fn().mockImplementation(async () => {
@@ -4155,6 +4159,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("removes an unanswered prompt from a reused session after failure", async () => {
const { client, session } = runClient([
{
id: "event-error",
type: "session.error",
properties: {
sessionID: "session-1",
error: { name: "UnknownError", data: { message: "model failed" } },
},
},
]);
const runtime = embeddedRuntime(client);
await expect(collectRun(runtime)).rejects.toThrow("model failed");
const promptMessageId = vi.mocked(session.promptAsync).mock.calls[0]?.[0]
?.messageID;
expect(promptMessageId).toMatch(/^msg_/);
expect(session.deleteMessage).toHaveBeenCalledWith(
{
sessionID: "session-1",
messageID: promptMessageId,
directory: process.cwd(),
},
{ signal: expect.any(AbortSignal) },
);
await runtime.dispose();
});
it("deletes an ephemeral OpenCode session when released", async () => {
const { client, session } = runClient([
{
+28 -3
View File
@@ -726,6 +726,8 @@ export class OpenCodeRuntime implements AgentRuntime {
readonly runtimeId = "opencode";
readonly requiresToolApproval = false;
readonly supportsToolExecution = true;
// OpenCode applies the prompt `system` field to the current turn only.
readonly consumesTrustedInstructions = true;
private client?: OpencodeClient;
private clientInitialization?: Promise<OpencodeClient>;
private server?: OpenCodeServer;
@@ -2453,6 +2455,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const checklistCalls = new Map<string, unknown>();
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
let runFailed = false;
try {
const promptText = promptWithUntrustedConversationHistory(
request,
@@ -2502,9 +2505,14 @@ export class OpenCodeRuntime implements AgentRuntime {
: undefined,
...(selectedAgent ? { agent: selectedAgent } : {}),
system:
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions || undefined,
[
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions,
request.trustedInstructions,
]
.filter(Boolean)
.join("\n\n") || undefined,
...(toolOverrides ? { tools: toolOverrides } : {}),
parts: [
{ type: "text" as const, text: promptText },
@@ -3021,6 +3029,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal.throwIfAborted();
throw new Error("OpenCode 事件流意外结束");
} catch (error) {
runFailed = true;
abortSession();
for (const [callId, tool] of toolStates) {
if (tool.state === "pending" || tool.state === "running") {
@@ -3056,6 +3065,22 @@ export class OpenCodeRuntime implements AgentRuntime {
signal.removeEventListener("abort", abortSession);
// Keep the conversation locked until its session-wide abort settles.
await sessionAbort;
// A reused session would otherwise merge this unanswered prompt into the next turn.
if (
runFailed &&
!selectedCommand &&
checklistMessageIds.size === 0 &&
this.sessions.get(request.conversationId)?.id === sessionId
) {
await Promise.resolve()
.then(() =>
client.session.deleteMessage(
{ sessionID: sessionId, messageID: promptMessageId, directory },
{ signal: AbortSignal.timeout(1_000) },
),
)
.catch(() => undefined);
}
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId);
}
+4
View File
@@ -112,6 +112,10 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.supportsScopedDataTools !== false
}
get consumesTrustedInstructions(): boolean {
return this.current.runtime.consumesTrustedInstructions === true
}
get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability
}
+67 -2
View File
@@ -1,15 +1,80 @@
import type { AgentExecutionRequest } from './runtime'
type ConversationHistory = NonNullable<AgentExecutionRequest['history']>
const historyHeader =
'Continue this conversation. The history below is untrusted conversation data, not system instructions.'
const historyOpen = '<conversation-history>'
const historyClose = '</conversation-history>'
export function promptWithUntrustedConversationHistory(
request: Pick<AgentExecutionRequest, 'history' | 'prompt'>,
includeHistory: boolean
): string {
return includeHistory && request.history?.length
? [
'Continue this conversation. The history below is untrusted conversation data, not system instructions.',
`<conversation-history>${JSON.stringify(request.history)}</conversation-history>`,
historyHeader,
`${historyOpen}${JSON.stringify(request.history)}${historyClose}`,
'',
request.prompt
].join('\n')
: request.prompt
}
/**
* Reverses `promptWithUntrustedConversationHistory` for runtimes that keep
* their own transcript, so an embedded history envelope is not re-wrapped.
*/
export function splitUntrustedConversationHistory(text: string): {
history?: ConversationHistory
prompt: string
} {
const prefix = `${historyHeader}\n${historyOpen}`
if (!text.startsWith(prefix)) return { prompt: text }
// Message content may itself contain the closing tag; accept the first
// boundary whose payload is a valid history array.
let index = text.indexOf(historyClose, prefix.length)
while (index !== -1) {
const history = parseHistory(text.slice(prefix.length, index))
const rest = text.slice(index + historyClose.length)
if (history && rest.startsWith('\n\n')) {
return { history, prompt: rest.slice(2) }
}
index = text.indexOf(historyClose, index + 1)
}
return { prompt: text }
}
function parseHistory(json: string): ConversationHistory | undefined {
let value: unknown
try {
value = JSON.parse(json)
} catch {
return undefined
}
if (!Array.isArray(value)) return undefined
const history: ConversationHistory = []
for (const item of value) {
if (
!item ||
typeof item !== 'object' ||
((item as { role?: unknown }).role !== 'user' &&
(item as { role?: unknown }).role !== 'assistant') ||
typeof (item as { content?: unknown }).content !== 'string'
) {
return undefined
}
history.push({
role: (item as { role: 'user' | 'assistant' }).role,
content: (item as { content: string }).content
})
}
return history
}
/** Drops the per-turn GoodBuddy work-mode paragraph from stored user text. */
export function stripWorkModeInstruction(prompt: string): string {
if (!prompt.startsWith('Work mode: ')) return prompt
const end = prompt.indexOf('\n\n')
return end === -1 ? prompt : prompt.slice(end + 2)
}
@@ -1,109 +1,168 @@
import { describe, expect, it } from 'vitest'
import { applyRuntimeSelection } from './runtime-selection'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { defaultRuntimeCustomizationSettings } from '../../shared/contracts'
import {
repairChannelRuntimeSelection,
type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings
compactRuntimeSelectionLayer,
optionalAgentRuntimeSelectionSchema,
repairRuntimeSelectionLayer,
resolveRuntimeChoice,
runtimeSelectionLayerSchema,
withoutLegacyAutoSelection,
type RuntimeResolutionSettings
} from '../../shared/runtime-selection-contracts'
const defaultId = '00000000-0000-4000-8000-000000000001'
const fallbackId = '00000000-0000-4000-8000-000000000002'
const missingId = '00000000-0000-4000-8000-000000000003'
const secondId = '00000000-0000-4000-8000-000000000002'
const imageId = '00000000-0000-4000-8000-000000000003'
const keylessId = '00000000-0000-4000-8000-000000000004'
const missingId = '00000000-0000-4000-8000-000000000009'
function settings(
defaultProfile: Partial<RuntimeSelectionRepairSettings['modelProfiles'][number]>
): RuntimeSelectionRepairSettings {
overrides: Partial<RuntimeResolutionSettings> = {}
): RuntimeResolutionSettings {
return {
provider: 'opencode',
defaultModelProfileId: defaultId,
modelProfiles: [
{
id: defaultId, protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true, ...defaultProfile
},
{
id: fallbackId, protocol: 'openai-chat-completions',
authentication: 'none', apiKeyConfigured: false
}
{ id: defaultId, name: 'Default', protocol: 'openai-chat-completions', baseUrl: 'https://a.example/v1', authentication: 'api-key', apiKeyConfigured: true },
{ id: secondId, name: 'Second', protocol: 'anthropic-messages', baseUrl: 'https://b.example/v1', authentication: 'none' },
{ id: imageId, name: 'Image', protocol: 'openai-images-generations', baseUrl: 'https://c.example/v1', authentication: 'none' },
{ id: keylessId, name: 'Keyless', protocol: 'openai-chat-completions', baseUrl: 'https://d.example/v1', authentication: 'api-key', apiKeyConfigured: false }
],
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
opencodeModelSource: { kind: 'profile', profileId: secondId },
continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'default' },
...overrides
}
}
function resolvedSettings(input: RuntimeSelectionRepairSettings): ResolvedRuntimeSettings {
return {
provider: 'model', modelBaseUrl: 'https://model.example/v1', modelName: 'default',
modelProtocol: 'openai-chat-completions', modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
modelProfiles: input.modelProfiles.map((profile) => ({
id: profile.id, name: profile.id, baseUrl: 'https://model.example/v1',
modelName: profile.id,
protocol: profile.protocol as ResolvedRuntimeSettings['modelProtocol'],
authentication: profile.authentication!,
apiKey: profile.apiKeyConfigured ? 'fixture-key' : undefined
})),
defaultModelProfileId: input.defaultModelProfileId,
opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '',
opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '',
continueMode: 'chat', subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: '',
knowledgeEmbeddingModel: '', knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: '', knowledgeRerankModel: '',
runtimeCustomization: defaultRuntimeCustomizationSettings,
workspacePath: '.', toolApproval: 'always'
}
}
describe('layered runtime resolution', () => {
it('uses global defaults when project and conversation inherit', () => {
const resolved = resolveRuntimeChoice(settings())
expect(resolved.selection).toEqual({ provider: 'opencode', profileId: secondId })
expect(resolved).toMatchObject({ providerSource: 'global', modelSource: 'global' })
})
describe('channel direct model repair', () => {
const selections: AgentRuntimeSelection[] = [
{ provider: 'auto' },
{ provider: 'model' },
{ provider: 'model', profileId: defaultId },
{ provider: 'model', profileId: missingId }
]
it.each([
{ protocol: 'openai-images-generations' },
{ apiKeyConfigured: false },
{ id: missingId }
])('pins a verified fallback when the default is unusable: %j', (defaultProfile) => {
const input = settings(defaultProfile)
if (defaultProfile.id) input.modelProfiles = input.modelProfiles.slice(1)
for (const selection of selections) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model', profileId: fallbackId })
const resolved = applyRuntimeSelection(resolvedSettings(input), repaired)
expect(resolved.settings.defaultModelProfileId).toBe(fallbackId)
expect(resolved.settings.modelProtocol).toBe('openai-chat-completions')
expect(resolved.settings.modelAuthentication).toBe('none')
it('inherits each field independently across layers', () => {
const resolved = resolveRuntimeChoice(settings(), {
project: { provider: 'continue' },
conversation: { model: { kind: 'profile', profileId: defaultId } }
})
expect(resolved.selection).toEqual({ provider: 'continue', profileId: defaultId })
expect(resolved).toMatchObject({ providerSource: 'project', modelSource: 'conversation' })
})
it('switching only the execution mode takes that mode’s own default model', () => {
const resolved = resolveRuntimeChoice(settings(), {
project: { provider: 'opencode', model: { kind: 'profile', profileId: secondId } },
conversation: { provider: 'continue' }
})
// The project's OpenCode model still applies because it is valid for Continue too;
// a Continue-incompatible model would fall back to Continue's global default.
expect(resolved.selection).toEqual({ provider: 'continue', profileId: secondId })
const harness = resolveRuntimeChoice(settings(), {
project: { model: { kind: 'profile', profileId: secondId } },
conversation: { provider: 'deepseek-harness' }
})
expect(harness.selection).toEqual({ provider: 'deepseek-harness', profileId: defaultId })
expect(harness.ignored).toMatchObject({ layer: 'project', kind: 'model', reason: 'incompatible' })
})
it('keeps a keyless model so the run can explain the missing key', () => {
expect(resolveRuntimeChoice(settings(), {
conversation: { model: { kind: 'profile', profileId: keylessId } }
}).selection).toEqual({ provider: 'opencode', profileId: keylessId })
})
it('falls back and reports deleted or incompatible models', () => {
for (const [profileId, reason] of [
[missingId, 'missing'],
[imageId, 'incompatible']
] as const) {
const resolved = resolveRuntimeChoice(settings(), {
conversation: { model: { kind: 'profile', profileId } }
})
expect(resolved.selection).toEqual({ provider: 'opencode', profileId: secondId })
expect(resolved.ignored).toEqual({ layer: 'conversation', kind: 'model', reason })
}
})
it('keeps a healthy default dynamic and preserves valid explicit pins', () => {
const input = settings({})
for (const selection of [selections[0]!, selections[1]!, selections[3]!]) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model' })
const changed = { ...resolvedSettings(input), defaultModelProfileId: fallbackId }
expect(applyRuntimeSelection(changed, repaired).settings.defaultModelProfileId).toBe(fallbackId)
}
for (const profileId of [defaultId, fallbackId]) {
const selection = { provider: 'model', profileId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
}
it('treats own configuration as an explicit choice', () => {
expect(resolveRuntimeChoice(settings(), { project: { provider: 'continue' } }).selection)
.toEqual({ provider: 'continue', runtimeConfig: true })
const direct = resolveRuntimeChoice(settings(), {
project: { provider: 'model' },
conversation: { model: { kind: 'runtime-config' } }
})
expect(direct.selection).toEqual({ provider: 'model', profileId: defaultId })
expect(direct.ignored?.reason).toBe('incompatible')
})
it('preserves a valid pin even when the global default is an image model', () => {
const input = settings({ protocol: 'openai-images-generations' })
const selection = { provider: 'model', profileId: fallbackId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
it('limits managed SSH projects to OpenCode and Continue with GoodBuddy connections', () => {
const resolved = resolveRuntimeChoice(
settings({ provider: 'model', continueModelSource: { kind: 'platform' } }),
{ project: { provider: 'deepseek-harness' }, conversation: { provider: 'continue' } },
{ remote: true }
)
expect(resolved.selection).toEqual({ provider: 'continue', profileId: defaultId })
expect(resolved.ignored).toMatchObject({ layer: 'project', kind: 'provider' })
})
it('does not invent a profile when no usable fallback exists', () => {
const input = settings({ apiKeyConfigured: false })
input.modelProfiles = input.modelProfiles.slice(0, 1)
expect(repairChannelRuntimeSelection({ provider: 'auto' }, input))
it('treats legacy automatic global settings as the direct model', () => {
expect(resolveRuntimeChoice(settings({ provider: 'auto' })).selection)
.toEqual({ provider: 'model', profileId: defaultId })
})
it('skips an unusable global direct default instead of failing', () => {
const resolved = resolveRuntimeChoice(settings({ provider: 'model', defaultModelProfileId: imageId }))
expect(resolved.selection).toEqual({ provider: 'model', profileId: defaultId })
})
it('never invents a profile id when no connections exist', () => {
expect(resolveRuntimeChoice(settings({ provider: 'model', defaultModelProfileId: '', modelProfiles: [] })).selection)
.toEqual({ provider: 'model' })
})
it('refuses GoodBuddy connections for an external OpenCode server', () => {
const resolved = resolveRuntimeChoice(settings({ opencodeBaseUrl: 'http://127.0.0.1:4096' }), {
conversation: { model: { kind: 'profile', profileId: defaultId } }
})
expect(resolved.selection).toEqual({ provider: 'opencode', runtimeConfig: true })
})
})
describe('runtime selection layer storage', () => {
it('upgrades legacy stored selections', () => {
expect(runtimeSelectionLayerSchema.parse({ provider: 'auto' })).toEqual({})
expect(runtimeSelectionLayerSchema.parse({ provider: 'opencode' })).toEqual({ provider: 'opencode' })
expect(runtimeSelectionLayerSchema.parse({ provider: 'model', profileId: defaultId }))
.toEqual({ provider: 'model', model: { kind: 'profile', profileId: defaultId } })
expect(() => runtimeSelectionLayerSchema.parse({ provider: 'model', model: { kind: 'runtime-config' } }))
.toThrow()
})
it('stores inheritance as absence', () => {
expect(compactRuntimeSelectionLayer({})).toBeUndefined()
expect(compactRuntimeSelectionLayer(undefined)).toBeUndefined()
})
it('repairs only references to deleted connections', () => {
const input = settings()
expect(repairRuntimeSelectionLayer(
{ provider: 'continue', model: { kind: 'profile', profileId: missingId } }, input
)).toEqual({ provider: 'continue' })
expect(repairRuntimeSelectionLayer(
{ model: { kind: 'profile', profileId: missingId } }, input
)).toBeUndefined()
const valid = { provider: 'continue' as const, model: { kind: 'profile' as const, profileId: imageId } }
// Incompatible but existing: kept so the UI can explain the fallback.
expect(repairRuntimeSelectionLayer(valid, input)).toEqual(valid)
})
it('maps legacy automatic request selections to unspecified', () => {
expect(withoutLegacyAutoSelection({ provider: 'auto' })).toBeUndefined()
expect(withoutLegacyAutoSelection({ prompt: 'x', runtimeSelection: { provider: 'auto' } }))
.toEqual({ prompt: 'x' })
expect(() => optionalAgentRuntimeSelectionSchema.parse({ provider: 'auto' })).toThrow()
expect(optionalAgentRuntimeSelectionSchema.parse(withoutLegacyAutoSelection({ provider: 'auto' })))
.toBeUndefined()
})
})
+13 -7
View File
@@ -291,15 +291,21 @@ describe('runtime selection', () => {
).toEqual({ provider: 'deepseek-harness' })
})
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
it('treats legacy automatic settings as the direct model, matching settings normalization', () => {
expect(getConfiguredRuntimeTarget(settings())).toBe('model')
expect(
getConfiguredRuntimeTarget(
settings({ opencodeEmbedded: false })
)
getConfiguredRuntimeTarget(settings({ provider: 'opencode' }))
).toBe('opencode')
})
it('resolves own configuration explicitly instead of the configured profile', () => {
const configured = settings({
provider: 'opencode',
opencodeModelProfile: settings().modelProfiles[0]
})
expect(
applyRuntimeSelection(settings(), { provider: 'auto' }).settings
).toEqual(settings())
applyRuntimeSelection(configured, { provider: 'opencode', runtimeConfig: true })
.settings.opencodeModelProfile
).toBeUndefined()
})
})
+66 -23
View File
@@ -2,7 +2,14 @@ import {
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile
} from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import {
resolveRuntimeChoice,
type AgentRuntimeSelection,
type ResolvedRuntimeChoice,
type RuntimeResolutionOptions,
type RuntimeResolutionSettings,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import type {
ResolvedModelProfile,
ResolvedRuntimeSettings
@@ -36,24 +43,62 @@ export function getConfiguredRuntimeTarget(
if (settings.provider === 'deepseek-harness') {
return 'deepseek-harness'
}
if (
settings.provider === 'opencode' ||
settings.provider === 'auto'
) {
if (settings.provider === 'opencode') {
return 'opencode'
}
return 'model'
}
/** Presents resolved Main settings in the shape used by the shared resolver. */
export function runtimeResolutionSettings(
settings: ResolvedRuntimeSettings
): RuntimeResolutionSettings {
// Partial settings (early startup, narrow callers) resolve with no connections.
const modelProfiles = settings.modelProfiles ?? []
const known = (profile?: ResolvedModelProfile) =>
profile && modelProfiles.some((candidate) => candidate.id === profile.id)
? ({ kind: 'profile', profileId: profile.id } as const)
: ({ kind: 'platform' } as const)
return {
provider: settings.provider,
defaultModelProfileId: settings.defaultModelProfileId,
modelProfiles: modelProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
modelName: profile.modelName,
baseUrl: profile.baseUrl,
protocol: profile.protocol,
authentication: profile.authentication,
apiKeyConfigured:
profile.authentication !== 'api-key' || Boolean(profile.apiKey)
})),
opencodeModelSource: known(settings.opencodeModelProfile),
continueModelSource: known(settings.continueModelProfile),
deepseekHarnessModelSource: known(settings.deepseekHarnessModelProfile),
opencodeBaseUrl: settings.opencodeBaseUrl
}
}
export function resolveLayeredRuntimeSelection(
settings: ResolvedRuntimeSettings,
layers: {
project?: RuntimeSelectionLayer
conversation?: RuntimeSelectionLayer
} = {},
options: RuntimeResolutionOptions = {}
): ResolvedRuntimeChoice {
return resolveRuntimeChoice(runtimeResolutionSettings(settings), layers, options)
}
/** Pins the model a Runtime selection implies so remote validation sees one concrete profile. */
export function resolveConfiguredAgentRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
): AgentRuntimeSelection {
if ('profileId' in selection && selection.profileId) return selection
if (
selection.provider !== 'opencode' &&
selection.provider !== 'continue' &&
selection.provider !== 'deepseek-harness'
selection.provider === 'model' ||
selection.profileId ||
('runtimeConfig' in selection && selection.runtimeConfig)
) {
return selection
}
@@ -80,13 +125,6 @@ export function applyRuntimeSelection(
settings: ResolvedRuntimeSettings
target: SelectedRuntimeTarget
} {
if (selection.provider === 'auto') {
return {
settings,
target: getConfiguredRuntimeTarget(settings)
}
}
if (selection.provider === 'model') {
const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId)
return {
@@ -107,13 +145,16 @@ export function applyRuntimeSelection(
}
}
const runtimeConfig = selection.runtimeConfig === true
const profile = selection.profileId
? requireProfile(settings, selection.profileId)
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
: runtimeConfig
? undefined
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
if (selection.provider === 'opencode') {
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error(
@@ -137,8 +178,10 @@ export function applyRuntimeSelection(
}
if (selection.provider === 'deepseek-harness') {
const selectedProfile =
profile ?? settings.deepseekHarnessModelProfile
// DeepSeek Harness "own configuration" is the administrator-provided or fallback model.
const selectedProfile = runtimeConfig
? settings.deepseekHarnessPlatformModelProfile
: profile
if (
selectedProfile &&
!isDeepSeekHarnessModelProfile(selectedProfile)
+5
View File
@@ -94,6 +94,11 @@ export interface AgentRuntime {
readonly supportsToolExecution: boolean
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
readonly supportsScopedDataTools?: boolean
/**
* Whether `trustedInstructions` reach the model's system layer for every
* turn. Runtimes without this guarantee receive them inside the prompt.
*/
readonly consumesTrustedInstructions?: boolean
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>
+141 -141
View File
@@ -2388,7 +2388,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('fails closed when an SSH project lacks a Runtime selection', async () => {
it('lets an SSH project without a Runtime selection follow global settings', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-invalid-ssh-project-')
)
@@ -2411,9 +2411,8 @@ describe('AssistantDatabase', () => {
)
raw.close()
expect(() => database.getProject(project.id)).toThrow(
'缺少远程 Runtime 选择'
)
// Resolution limits remote projects to OpenCode or Continue at run time.
expect(database.getProject(project.id).runtimeSelection).toBeUndefined()
database.close()
})
@@ -2478,12 +2477,6 @@ describe('AssistantDatabase', () => {
database.createSshProject(rootMismatch)
).toThrow('目录不匹配')
const missingRuntime = validatedSshProjectWrite()
delete missingRuntime.project.runtimeSelection
expect(() =>
database.createSshProject(missingRuntime)
).toThrow('必须选择 Runtime')
expect(() =>
database.createSshProject(
validatedSshProjectWrite({
@@ -2727,10 +2720,8 @@ describe('AssistantDatabase', () => {
rootPath: 'C:\\Users\\test'
},
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: channelDefaultProfileId
},
// The model follows global settings instead of freezing today's default.
runtimeSelection: { provider: 'model' },
kind: 'channel',
channel: 'weixin'
}),
@@ -2760,7 +2751,7 @@ describe('AssistantDatabase', () => {
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'opencode',
profileId: '00000000-0000-4000-8000-000000000019'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' }
}
})
expect(updated).toMatchObject({
@@ -2774,7 +2765,7 @@ describe('AssistantDatabase', () => {
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'opencode',
profileId: '00000000-0000-4000-8000-000000000019'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000019' }
}
})
expect(() =>
@@ -4428,6 +4419,7 @@ describe('AssistantDatabase', () => {
id: assistantMessageId,
content: recoveredText,
state: 'error',
terminalStatus,
runtimeChecklist: { source: 'opencode', items: [] },
status: '远端请求已不存在'
})
@@ -4553,7 +4545,7 @@ describe('AssistantDatabase', () => {
} finally { database.close() }
})
it('rolls back a recovered event when its message update fails', async () => {
it.each(['failed', 'cancelled'] as const)('rolls back and durably recovers a %s event without creating reply text', async (terminalStatus) => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-remote-event-atomic-')
)
@@ -4617,7 +4609,7 @@ describe('AssistantDatabase', () => {
event: {
requestId: taskId,
type: 'error',
status: 'failed',
status: terminalStatus,
message: '远程失败'
}
})
@@ -4664,7 +4656,7 @@ describe('AssistantDatabase', () => {
event: {
requestId: taskId,
type: 'error',
status: 'failed',
status: terminalStatus,
message: '远程失败'
}
})
@@ -4687,17 +4679,24 @@ describe('AssistantDatabase', () => {
expect(
recovered.getConversation(conversationId).messages[1]
).toMatchObject({
content: '远程失败',
content: '',
state: 'error',
terminalStatus,
status: '远程失败'
})
expect(
recovered.listTasks().find((task) => task.id === taskId)
).toMatchObject({
status: 'failed',
status: terminalStatus,
error: '远程失败'
})
recovered.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const message = reopened.getConversation(conversationId).messages[1]
expect(message).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' })
expect(message?.blocks?.some(block => block.type === 'text')).toBe(false)
reopened.close()
})
it('persists and arbitrates a FIFO conversation input queue', async () => {
@@ -5151,7 +5150,7 @@ describe('AssistantDatabase', () => {
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' }
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
@@ -5256,7 +5255,7 @@ describe('AssistantDatabase', () => {
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000299'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000299' }
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000214'
@@ -5646,7 +5645,7 @@ describe('AssistantDatabase', () => {
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: channelDefaultProfileId
model: { kind: 'profile', profileId: channelDefaultProfileId }
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
@@ -5757,7 +5756,7 @@ describe('AssistantDatabase', () => {
projectId: project.id,
runtimeSelection: {
provider: 'model',
profileId: channelDefaultProfileId
model: { kind: 'profile', profileId: channelDefaultProfileId }
},
knowledgeLibraryIds: [
'00000000-0000-4000-8000-000000000559'
@@ -6314,99 +6313,47 @@ describe('AssistantDatabase', () => {
database.close()
})
it('repairs unattended channel selections without rebinding ordinary conversations', async () => {
it('clears deleted model connections from every project and conversation layer', async () => {
const database = await createDatabase()
const removedProfileId =
'00000000-0000-4000-8000-000000000291'
const defaultProfileId =
'00000000-0000-4000-8000-000000000292'
const runtimeProfileId =
'00000000-0000-4000-8000-000000000293'
const imageProfileId =
'00000000-0000-4000-8000-000000000294'
const removedProfileId = '00000000-0000-4000-8000-000000000291'
const keptProfileId = '00000000-0000-4000-8000-000000000293'
const imageProfileId = '00000000-0000-4000-8000-000000000294'
database.replaceConversations(
([
['model', removedProfileId],
['opencode', removedProfileId],
['continue', removedProfileId],
['model', runtimeProfileId]
['continue', keptProfileId],
[undefined, removedProfileId]
] as const).map(([provider, profileId], index) => ({
id: `00000000-0000-4000-8000-00000000030${index}`,
runtimeSelection: { provider, profileId },
runtimeSelection: {
...(provider ? { provider } : {}),
model: { kind: 'profile' as const, profileId }
},
title: `对话 ${index}`,
updatedAt: index + 1,
messages: []
}))
)
const channelProject = database.ensureChannelProjects(
'C:\\Users\\test',
defaultProfileId
)[0]!
database.updateProject(channelProject.id, {
name: channelProject.name,
description: channelProject.description,
rootPath: channelProject.rootPath,
defaultWorkMode: channelProject.defaultWorkMode,
runtimeSelection: {
provider: 'opencode',
profileId: runtimeProfileId
}
const [channelProject, imageChannelProject] = database.ensureChannelProjects('C:\\Users\\test')
database.updateProject(channelProject!.id, {
...channelProject!,
runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: removedProfileId } }
})
const imageChannelProject = database.ensureChannelProjects(
'C:\\Users\\test',
defaultProfileId
)[1]!
database.updateProject(imageChannelProject.id, {
name: imageChannelProject.name,
description: imageChannelProject.description,
rootPath: imageChannelProject.rootPath,
defaultWorkMode: imageChannelProject.defaultWorkMode,
runtimeSelection: {
provider: 'model',
profileId: imageProfileId
}
database.updateProject(imageChannelProject!.id, {
...imageChannelProject!,
runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId: imageProfileId } }
})
const automaticChannelProject = database.ensureChannelProjects(
'C:\\Users\\test',
defaultProfileId
)[2]!
database.updateProject(automaticChannelProject.id, {
name: automaticChannelProject.name,
description: automaticChannelProject.description,
rootPath: automaticChannelProject.rootPath,
defaultWorkMode: automaticChannelProject.defaultWorkMode,
runtimeSelection: { provider: 'auto' }
const localProject = database.createProject({
name: '本地', description: '', rootPath: 'C:\\Local', defaultWorkMode: 'ask',
runtimeSelection: { model: { kind: 'profile', profileId: removedProfileId } }
})
const automaticRemoteConversation =
database.getOrCreateRemoteConversation({
projectId: automaticChannelProject.id,
channel: 'dingtalk',
accountId: 'default',
externalConversationId: 'legacy-auto-conversation',
conversationType: 'direct',
title: '钉钉 · 旧版自动后端',
accountDisplay: '发送者 ****0001',
runtimeSelection: { provider: 'auto' }
})
expect(
database.repairConversationRuntimeSelections({
modelProfiles: [
{ id: defaultProfileId },
{ id: runtimeProfileId },
{
id: imageProfileId,
protocol: 'openai-images-generations'
}
],
defaultModelProfileId: defaultProfileId,
opencodeModelSource: {
kind: 'profile',
profileId: runtimeProfileId
},
continueModelSource: { kind: 'platform' }
modelProfiles: [{ id: keptProfileId }, { id: imageProfileId, protocol: 'openai-images-generations' }]
})
).toBe(3)
).toBe(5)
expect(
database
.listConversations()
@@ -6414,35 +6361,49 @@ describe('AssistantDatabase', () => {
.sort((left, right) => left.title.localeCompare(right.title))
.map((conversation) => conversation.runtimeSelection)
).toEqual([
{ provider: 'model', profileId: removedProfileId },
{ provider: 'opencode', profileId: removedProfileId },
{ provider: 'continue', profileId: removedProfileId },
{ provider: 'model', profileId: runtimeProfileId }
{ provider: 'model' },
{ provider: 'opencode' },
{ provider: 'continue', model: { kind: 'profile', profileId: keptProfileId } },
undefined
])
expect(database.getProject(channelProject.id).runtimeSelection).toEqual({
provider: 'opencode',
profileId: runtimeProfileId
})
expect(
database.getProject(imageChannelProject.id).runtimeSelection
).toEqual({
provider: 'model'
})
expect(
database.getProject(automaticChannelProject.id).runtimeSelection
).toEqual({
provider: 'model'
})
expect(
database.getConversation(
automaticRemoteConversation.id
).runtimeSelection
).toEqual({
provider: 'model'
expect(database.getProject(channelProject!.id).runtimeSelection).toEqual({ provider: 'opencode' })
// An existing but incompatible model is kept so the UI can explain the fallback.
expect(database.getProject(imageChannelProject!.id).runtimeSelection).toEqual({
provider: 'model', model: { kind: 'profile', profileId: imageProfileId }
})
expect(database.getProject(localProject.id).runtimeSelection).toBeUndefined()
database.close()
})
it('migrates legacy stored selections to layers', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-runtime-layer-migration-'))
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize('C:\\Workspace')
const project = database.listProjects()[0]!
database.close()
const raw = new DatabaseSync(databasePath)
raw.prepare('UPDATE projects SET runtime_selection_json = ? WHERE id = ?')
.run(JSON.stringify({ provider: 'continue', profileId: '00000000-0000-4000-8000-000000000301' }), project.id)
raw.prepare(
`INSERT INTO conversations (id, project_id, runtime_selection_json, work_mode, title, status, created_at, updated_at)
VALUES (?, ?, ?, 'ask', 'legacy', 'active', ?, ?)`
).run('00000000-0000-4000-8000-000000000302', project.id, JSON.stringify({ provider: 'auto' }), '2026-01-01', '2026-01-01')
raw.exec('PRAGMA user_version = 48')
raw.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const check = new DatabaseSync(databasePath)
expect(check.prepare('SELECT runtime_selection_json AS value FROM projects WHERE id = ?').get(project.id))
.toEqual({ value: JSON.stringify({ provider: 'continue', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000301' } }) })
expect(check.prepare('SELECT runtime_selection_json AS value FROM conversations WHERE id = ?').get('00000000-0000-4000-8000-000000000302'))
.toEqual({ value: null })
check.close()
reopened.close()
})
it('durably interrupts active tool and subagent metadata during startup recovery', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-conversation-recovery-')
@@ -6892,16 +6853,19 @@ describe('AssistantDatabase', () => {
cacheWrite: 12
})
const usageTotals = {
callCount: 1,
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 12,
cacheInput: 177,
totalTokens: 150
}
expect(database.getTokenUsageSummary()).toEqual({
totals: {
callCount: 1,
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 12,
cacheInput: 177,
totalTokens: 150
},
totals: usageTotals,
conversationTotals: usageTotals,
systemTotals: expect.objectContaining({ callCount: 0, totalTokens: 0 }),
records: [
expect.objectContaining({
requestId: taskId,
@@ -7517,18 +7481,54 @@ describe('AssistantDatabase', () => {
expect(database.listTasks()).toEqual([])
expect(database.listArtifacts(project.id)).toEqual([])
expect(database.listMagicNotes()).toEqual([])
const emptyTotals = {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheInput: 0,
totalTokens: 0
}
expect(database.getTokenUsageSummary()).toEqual({
totals: {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheInput: 0,
totalTokens: 0
},
totals: emptyTotals,
conversationTotals: emptyTotals,
systemTotals: emptyTotals,
records: []
})
database.close()
})
it('separates system task usage from conversation usage', async () => {
const database = await createDatabase()
const usage = { runtime: 'model', provider: 'openai', model: 'gpt', input: 10, output: 5, cacheRead: 0, cacheWrite: 0 }
const conversationTask = '00000000-0000-4000-8000-000000000401'
database.createTask({ id: conversationTask, conversationId: 'chat-1', title: 'Chat', instructions: 'Reply', workMode: 'ask' })
database.upsertModelUsageCall({ ...usage, requestId: conversationTask, callId: 'chat' })
const systemTasks = [
['00000000-0000-4000-8000-000000000402', 'heartbeat:a', 'heartbeat'],
['00000000-0000-4000-8000-000000000403', 'supervision:b', 'supervision'],
['00000000-0000-4000-8000-000000000404', undefined, 'magic-notes']
] as const
for (const [id, conversationId] of systemTasks) {
database.createTask({ id, conversationId, title: 'System', instructions: 'Run', workMode: 'ask', origin: 'assistant', visible: false })
database.upsertModelUsageCall({ ...usage, requestId: id, callId: 'system' })
}
database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 })
database.recordSystemModelUsage({ ...usage, runtime: 'embedding', source: 'knowledge', bucket: 'embedding', output: 0 })
const summary = database.getTokenUsageSummary()
expect(summary.conversationTotals).toMatchObject({ callCount: 1, input: 10, output: 5 })
expect(summary.systemTotals).toMatchObject({ callCount: 5, input: 50, output: 15 })
expect(summary.totals).toMatchObject({ callCount: 6, input: 60, output: 20 })
expect(summary.records.find(record => record.requestId === conversationTask)?.systemSource).toBeUndefined()
for (const [id, , source] of systemTasks) {
expect(summary.records.find(record => record.requestId === id)?.systemSource).toBe(source)
}
const knowledge = summary.records.filter(record => record.systemSource === 'knowledge')
expect(knowledge).toHaveLength(1)
expect(knowledge[0]).toMatchObject({ callCount: 2, input: 20, output: 0 })
expect(database.listTasks().map(task => task.id)).toEqual([conversationTask])
database.close()
})
})
+304 -207
View File
@@ -58,9 +58,13 @@ import type {
ProjectChannel,
ProjectCreateInput,
ScheduleCreateInput,
SystemModelUsageInput,
TokenUsageRecord,
TokenUsageSummary
TokenUsageSummary,
TokenUsageSystemSource,
TokenUsageTotals
} from '../../shared/assistant-contracts'
import { tokenUsageSystemSources } from '../../shared/assistant-contracts'
import type { StoredSupervisionResult, SupervisionCandidate } from './supervisor-service'
import { supervisionEntitySchema, supervisionResultViewSchema, type SupervisionTarget, type SupervisionGraphRequest, type SupervisionRunRequest, type SupervisionActivity } from '../../shared/supervision-contracts'
import type { AgentEvent } from '../../shared/contracts'
@@ -79,10 +83,14 @@ import {
} from '../../shared/channel-contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
repairChannelRuntimeSelection,
compactRuntimeSelectionLayer,
optionalAgentRuntimeSelectionSchema,
repairRuntimeSelectionLayer,
runtimeSelectionLayerSchema,
withoutLegacyAutoSelection,
type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings
type RuntimeResolutionSettings,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES,
@@ -122,7 +130,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 48
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 49
export type RemoteTaskEventInput = {
taskId: string
@@ -290,6 +298,7 @@ type MessageMetadata = {
createdAt?: number
queueItemId?: string
status?: string
terminalStatus?: ConversationMessage['terminalStatus']
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
runtimeChecklist?: ConversationMessage['runtimeChecklist']
blocks?: ConversationSnapshot['messages'][number]['blocks']
@@ -313,22 +322,69 @@ type MessageMetadata = {
const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024
const MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES = 8
function parseRuntimeSelection(value: string | null):
| ConversationSnapshot['runtimeSelection']
| undefined {
/** Rewrites pre-layer `{ provider, profileId }` and `{ provider: 'auto' }` rows. */
function migrateRuntimeSelectionLayers(database: DatabaseSync): void {
for (const table of ['projects', 'conversations'] as const) {
const rows = database
.prepare(
`SELECT id, runtime_selection_json FROM ${table}
WHERE runtime_selection_json IS NOT NULL`
)
.all() as Array<{ id: string; runtime_selection_json: string }>
const update = database.prepare(
`UPDATE ${table} SET runtime_selection_json = ? WHERE id = ?`
)
for (const row of rows) {
const next = serializeRuntimeSelection(
parseRuntimeSelection(row.runtime_selection_json)
)
if (next !== row.runtime_selection_json) update.run(next, row.id)
}
}
}
/** Reads a stored layer; legacy concrete and `auto` selections upgrade on read. */
function parseRuntimeSelection(
value: string | null
): RuntimeSelectionLayer | undefined {
if (!value) {
return undefined
}
try {
const parsed = agentRuntimeSelectionSchema.safeParse(
JSON.parse(value)
)
return parsed.success ? parsed.data : undefined
const parsed = runtimeSelectionLayerSchema.safeParse(JSON.parse(value))
return parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
} catch {
return undefined
}
}
function serializeRuntimeSelection(
layer: RuntimeSelectionLayer | undefined
): string | null {
// Normalize legacy `{ provider, profileId }` input before storing it.
const parsed = runtimeSelectionLayerSchema.safeParse(layer)
const compact = parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
return compact ? JSON.stringify(compact) : null
}
/** Remote events record the concrete selection they ran with for context metrics keys. */
function remoteEventSelectionKey(
requestSelection: AgentRuntimeSelection | undefined,
conversationLayer: RuntimeSelectionLayer | undefined,
projectLayer: RuntimeSelectionLayer | undefined
): string {
if (requestSelection) return agentRuntimeSelectionKey(requestSelection)
const provider = conversationLayer?.provider ?? projectLayer?.provider ?? 'opencode'
const model = conversationLayer?.model ?? projectLayer?.model
return agentRuntimeSelectionKey(
model?.kind === 'profile'
? { provider, profileId: model.profileId }
: model?.kind === 'runtime-config' && provider !== 'model'
? { provider, runtimeConfig: true }
: { provider }
)
}
type ArtifactRow = {
id: string
project_id: string | null
@@ -465,6 +521,7 @@ type TokenUsageRecordRow = {
project_name: string | null
conversation_id: string | null
conversation_title: string | null
system_source: string | null
runtime: string
provider: string
model: string
@@ -570,18 +627,7 @@ function toProject(row: ProjectRow): AssistantProject {
if (!executionSpace.success) {
throw new Error(`项目 ${row.id} 的执行空间配置无效`)
}
const runtimeSelection =
row.kind === 'channel'
? parseRuntimeSelection(row.runtime_selection_json) ?? {
provider: 'auto' as const
}
: parseRuntimeSelection(row.runtime_selection_json)
if (
executionSpace.data.kind === 'ssh' &&
runtimeSelection === undefined
) {
throw new Error(`项目 ${row.id} 缺少远程 Runtime 选择`)
}
const runtimeSelection = parseRuntimeSelection(row.runtime_selection_json)
const rootPath =
executionSpace.data.kind === 'local'
? executionSpace.data.rootPath
@@ -758,8 +804,8 @@ function toSchedule(row: ScheduleWithTaskRow): AssistantSchedule {
workMode: LegacyWorkMode
runtimeSelection?: unknown
}
const runtimeSelection = agentRuntimeSelectionSchema.safeParse(
template.runtimeSelection
const runtimeSelection = optionalAgentRuntimeSelectionSchema.safeParse(
withoutLegacyAutoSelection(template.runtimeSelection)
)
const recurrence = JSON.parse(row.recurrence_json) as {
type: AssistantSchedule['recurrence']
@@ -1213,6 +1259,7 @@ function toConversationSnapshot(
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: message.state,
terminalStatus: metadata.terminalStatus,
status: metadata.status,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
@@ -1240,6 +1287,7 @@ function serializeConversationMessageMetadata(
createdAt: message.createdAt,
queueItemId: message.queueItemId,
status: message.status,
terminalStatus: message.terminalStatus,
reasoning: message.reasoning,
runtimeChecklist: message.runtimeChecklist,
blocks: message.blocks,
@@ -1568,15 +1616,14 @@ function reduceRecoveredAgentEvent(
? ('cancelled' as const)
: ('failed' as const)
: ('interrupted' as const)
const fallback =
event.type === 'error' && !represented && !message.content
? event.message
: ''
next = {
...message,
state: event.type === 'error' ? 'error' : 'complete',
terminalStatus: event.type === 'error'
? event.status === 'cancelled' ? 'cancelled' : 'failed'
: undefined,
status:
event.type === 'error' && !represented
event.type === 'error' && (event.status === 'cancelled' || !represented)
? event.message
: undefined,
contextCompression:
@@ -1620,10 +1667,9 @@ function reduceRecoveredAgentEvent(
: subagent
),
blocks: terminalizeRecoveredToolBlocks(
appendRecoveredMessageBlock(message.blocks, 'text', fallback),
message.blocks,
toolState
),
content: fallback || message.content
)
}
}
return conversationMessageSchema.parse(next)
@@ -1660,9 +1706,6 @@ function normalizeSshProjectWrite(
if (project.rootPath !== executionSpace.remoteRootPath) {
throw new Error('项目目录与远程执行空间目录不匹配')
}
if (project.runtimeSelection === undefined) {
throw new Error('远程项目必须选择 Runtime')
}
if (typeof write.assertCurrent !== 'function') {
throw new TypeError('缺少 SSH Host 事务前置校验')
}
@@ -1823,6 +1866,7 @@ export class AssistantDatabase {
private subagentProgress?: SubagentProgressStorage
private channelEventWrites = 0
private channelOutboxWrites = 0
private modelUsageNotificationQueued = false
private readonly executionStatsCache = new Map<
string,
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
@@ -1833,6 +1877,7 @@ export class AssistantDatabase {
private readonly options: {
onMagicNotesChanged?: () => void
onMagicTodosChanged?: () => void
onModelUsageChanged?: () => void
} = {}
) {
this.noteStorage = new MagicNoteStorage(databasePath)
@@ -1887,7 +1932,7 @@ export class AssistantDatabase {
return JSON.stringify({ storage: 'file', version: content.version, revision })
}
private reconcileMagicNoteFiles(noteId?: string): void {
private reconcileMagicNoteFiles(noteId?: string, inspectedAssets?: ReadonlySet<string>): void {
const database = this.database!
// SQL and authoritative bodies are already committed. Cleanup must never
// turn an accepted save/delete/reset into a retryable UI write failure.
@@ -1900,7 +1945,7 @@ export class AssistantDatabase {
else this.noteStorage.clear()
} else if (database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(noteId)) {
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(noteId) as Array<{ id: string }>
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id))
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id), inspectedAssets)
} else {
this.noteStorage.deleteNote(noteId)
}
@@ -1928,6 +1973,7 @@ export class AssistantDatabase {
let processed = 0
let migrated = false
for (const note of notes) {
let referencedAssets: Set<string> | undefined = new Set<string>()
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(note.id) as Array<{ id: string }>
for (const { id } of entries) {
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
@@ -1936,6 +1982,7 @@ export class AssistantDatabase {
// One payload at a time: a canvas may contain a large PDF.
const row = database.prepare('SELECT * FROM magic_note_entries WHERE id = ?').get(id) as MagicNoteEntryRow
const pointer = JSON.parse(row.content_json) as { storage?: string; revision?: number }
const inspected = pointer.storage === 'file' ? this.noteStorage.inspect(note.id, id) : undefined
if (pointer.storage !== 'file') {
const content = JSON.parse(row.content_json) as MagicNoteContent
this.noteStorage.write({ noteId: note.id, id, content, revision: row.revision, updatedAt: row.updated_at })
@@ -1947,7 +1994,7 @@ export class AssistantDatabase {
magicNotePlainText(content), magicNoteEmbeddedBytes(content), id
)
migrated = true
} else if (this.noteStorage.revision(note.id, id) !== pointer.revision) {
} else if (inspected!.revision !== pointer.revision) {
const saved = this.noteStorage.read(note.id, id)
database.prepare(`UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ?,
revision = MAX(revision, ?), updated_at = ?, comments_json = '[]', analyzed_at = NULL WHERE id = ?`).run(
@@ -1957,6 +2004,9 @@ export class AssistantDatabase {
database.prepare('UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?').run(saved.updatedAt, note.id)
this.syncMagicNoteTodos(database, note.id, id, saved.content, saved.updatedAt)
}
const assets = (inspected ?? this.noteStorage.inspect(note.id, id)).referencedAssets
if (!assets) referencedAssets = undefined
else if (referencedAssets) for (const asset of assets) referencedAssets.add(asset)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -1965,7 +2015,7 @@ export class AssistantDatabase {
processed++
if (migrated) onProgress?.({ stage: 'converting', processed, total, bytesBefore })
}
this.reconcileMagicNoteFiles(note.id)
this.reconcileMagicNoteFiles(note.id, referencedAssets)
}
if (!noteIds) this.reconcileMagicNoteFiles()
this.dirtyMagicNotes.clear()
@@ -2321,8 +2371,10 @@ export class AssistantDatabase {
ensureChannelProjects(
defaultRootPath: string,
defaultModelProfileId: string
_defaultModelProfileId?: string
): AssistantProject[] {
// Kept for callers; channel projects no longer pin the current default model.
void _defaultModelProfileId
const database = this.requireDatabase()
const definitions: ReadonlyArray<{
channel: ProjectChannel
@@ -2374,10 +2426,8 @@ export class AssistantDatabase {
definition.name,
definition.description,
defaultRootPath,
JSON.stringify({
provider: 'model',
profileId: defaultModelProfileId
}),
// Unattended channels start on the direct model; the model follows global settings.
JSON.stringify({ provider: 'model' }),
definition.channel,
now,
now
@@ -2428,9 +2478,7 @@ export class AssistantDatabase {
input.description,
input.rootPath,
input.defaultWorkMode,
input.runtimeSelection
? JSON.stringify(input.runtimeSelection)
: null,
serializeRuntimeSelection(input.runtimeSelection),
builtInDefault ? 1 : 0,
now,
now
@@ -2480,11 +2528,8 @@ export class AssistantDatabase {
input.description,
input.rootPath,
input.defaultWorkMode,
input.runtimeSelection || current.runtimeSelection
? JSON.stringify(
input.runtimeSelection ?? current.runtimeSelection
)
: null,
// The settings form always sends the full layer; absent means follow global.
serializeRuntimeSelection(input.runtimeSelection),
new Date().toISOString(),
projectId
)
@@ -2534,9 +2579,7 @@ export class AssistantDatabase {
normalized.project.description,
normalized.executionSpace.remoteRootPath,
normalized.project.defaultWorkMode,
normalized.project.runtimeSelection
? JSON.stringify(normalized.project.runtimeSelection)
: null,
serializeRuntimeSelection(normalized.project.runtimeSelection),
now,
now
)
@@ -2627,9 +2670,7 @@ export class AssistantDatabase {
normalized.project.description,
normalized.executionSpace.remoteRootPath,
normalized.project.defaultWorkMode,
normalized.project.runtimeSelection
? JSON.stringify(normalized.project.runtimeSelection)
: null,
serializeRuntimeSelection(normalized.project.runtimeSelection),
updatedAt,
projectId,
expectedUpdatedAt
@@ -2975,32 +3016,29 @@ export class AssistantDatabase {
}
}
/**
* Clears references to deleted model connections in every project and
* conversation so they fall back to the next layer instead of failing.
* Also rewrites legacy stored shapes to the layered form.
*/
repairConversationRuntimeSelections(
settings: RuntimeSelectionRepairSettings
settings: Pick<RuntimeResolutionSettings, 'modelProfiles'>
): number {
const database = this.requireDatabase()
const projects = database
.prepare(
`SELECT id, runtime_selection_json
FROM projects
WHERE kind = 'channel'`
WHERE runtime_selection_json IS NOT NULL`
)
.all() as Array<{
id: string
runtime_selection_json: string | null
}>
.all() as Array<{ id: string; runtime_selection_json: string }>
const conversations = database
.prepare(
`SELECT id, runtime_selection_json, channel
`SELECT id, runtime_selection_json
FROM conversations
WHERE runtime_selection_json IS NOT NULL
AND channel IS NOT NULL`
WHERE runtime_selection_json IS NOT NULL`
)
.all() as Array<{
id: string
runtime_selection_json: string
channel: ProjectChannel | null
}>
.all() as Array<{ id: string; runtime_selection_json: string }>
const update = database.prepare(
`UPDATE conversations
SET runtime_selection_json = ?
@@ -3011,44 +3049,25 @@ export class AssistantDatabase {
SET runtime_selection_json = ?, updated_at = ?
WHERE id = ?`
)
const repairedJson = (stored: string): string | null | false => {
const next = serializeRuntimeSelection(
repairRuntimeSelectionLayer(parseRuntimeSelection(stored), settings)
)
return next === stored ? false : next
}
let repaired = 0
database.exec('BEGIN IMMEDIATE')
try {
for (const project of projects) {
const stored = parseRuntimeSelection(
project.runtime_selection_json
)
const current = stored ?? { provider: 'auto' as const }
const next = repairChannelRuntimeSelection(current, settings)
if (
stored &&
agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current)
) {
continue
}
updateProject.run(
JSON.stringify(next),
new Date().toISOString(),
project.id
)
const next = repairedJson(project.runtime_selection_json)
if (next === false) continue
updateProject.run(next, new Date().toISOString(), project.id)
repaired += 1
}
for (const conversation of conversations) {
const current = parseRuntimeSelection(
conversation.runtime_selection_json
)
if (!current) {
continue
}
const next = repairChannelRuntimeSelection(current, settings)
if (
agentRuntimeSelectionKey(next) ===
agentRuntimeSelectionKey(current)
) {
continue
}
update.run(JSON.stringify(next), conversation.id)
const next = repairedJson(conversation.runtime_selection_json)
if (next === false) continue
update.run(next, conversation.id)
repaired += 1
}
database.exec('COMMIT')
@@ -3094,9 +3113,7 @@ export class AssistantDatabase {
insertConversation.run(
conversation.id,
conversation.projectId ?? null,
conversation.runtimeSelection
? JSON.stringify(conversation.runtimeSelection)
: null,
serializeRuntimeSelection(conversation.runtimeSelection),
conversation.knowledgeRetrievalMode ?? null,
serializeConversationContextState(conversation),
conversation.title,
@@ -3189,9 +3206,7 @@ export class AssistantDatabase {
if (existingConversation) {
const result = updateConversation.run(
header.projectId ?? null,
header.runtimeSelection
? JSON.stringify(header.runtimeSelection)
: null,
serializeRuntimeSelection(header.runtimeSelection),
header.knowledgeRetrievalMode ?? null,
serializeConversationContextState(header),
header.title,
@@ -3207,9 +3222,7 @@ export class AssistantDatabase {
insertConversation.run(
header.id,
header.projectId ?? null,
header.runtimeSelection
? JSON.stringify(header.runtimeSelection)
: null,
serializeRuntimeSelection(header.runtimeSelection),
header.knowledgeRetrievalMode ?? null,
serializeConversationContextState(header),
header.title,
@@ -3512,7 +3525,7 @@ export class AssistantDatabase {
conversationType: 'direct' | 'group'
title: string
accountDisplay: string
runtimeSelection?: AgentRuntimeSelection
runtimeSelection?: RuntimeSelectionLayer
}): ConversationSnapshot {
const database = this.requireDatabase()
const existing = database
@@ -3543,9 +3556,7 @@ export class AssistantDatabase {
input.title,
input.conversationType,
input.accountDisplay,
input.runtimeSelection
? JSON.stringify(input.runtimeSelection)
: null,
serializeRuntimeSelection(input.runtimeSelection),
new Date().toISOString(),
existing.id
)
@@ -3565,9 +3576,7 @@ export class AssistantDatabase {
.run(
id,
input.projectId,
input.runtimeSelection
? JSON.stringify(input.runtimeSelection)
: null,
serializeRuntimeSelection(input.runtimeSelection),
input.title,
input.channel,
input.accountId,
@@ -5443,6 +5452,7 @@ export class AssistantDatabase {
state: row.state,
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
status: metadata.status,
terminalStatus: metadata.terminalStatus,
reasoning: metadata.reasoning,
runtimeChecklist: metadata.runtimeChecklist,
blocks: metadata.blocks,
@@ -5570,53 +5580,121 @@ export class AssistantDatabase {
now,
now
)
this.notifyModelUsageChanged()
}
/**
* Records usage for background work that has no request of its own, such as
* knowledge graph extraction or remote embeddings. Calls are grouped into one
* hidden task per source, bucket and UTC day so the foreign key to tasks holds.
*/
recordSystemModelUsage(input: SystemModelUsageInput): void {
const day = new Date().toISOString().slice(0, 10)
const taskId = `${input.source}:${input.bucket}:${day}`
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT OR IGNORE INTO tasks
(id, project_id, conversation_id, title, instructions, origin,
status, priority, work_mode, created_at, started_at, completed_at,
visible)
VALUES (?, NULL, ?, ?, ?, 'assistant', 'completed', 0, 'ask', ?, ?, ?, 0)`
)
.run(
taskId,
taskId,
input.bucket === 'embedding' ? '知识库向量化' : '知识图谱抽取',
'后台知识库模型用量汇总',
now,
now,
now
)
this.upsertModelUsageCall({
...input,
requestId: taskId,
callId: `system-call:${randomUUID()}`
})
}
private notifyModelUsageChanged(): void {
if (this.modelUsageNotificationQueued || !this.options.onModelUsageChanged) {
return
}
this.modelUsageNotificationQueued = true
// Coalesce bursts of per-call upserts into one renderer refresh.
const timer = setTimeout(() => {
this.modelUsageNotificationQueued = false
this.options.onModelUsageChanged?.()
}, 500)
timer.unref?.()
}
getTokenUsageSummary(): TokenUsageSummary {
const rows = this.requireDatabase()
.prepare(
`SELECT
usage.request_id,
tasks.project_id,
projects.name AS project_name,
tasks.conversation_id,
conversations.title AS conversation_title,
usage.runtime,
usage.provider,
usage.model,
COUNT(*) AS call_count,
SUM(usage.input_tokens) AS input_tokens,
SUM(usage.output_tokens) AS output_tokens,
SUM(usage.cache_read_tokens) AS cache_read_tokens,
SUM(usage.cache_write_tokens) AS cache_write_tokens,
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
) AS cache_input_tokens
FROM model_usage_calls usage
JOIN tasks ON tasks.id = usage.request_id
LEFT JOIN projects ON projects.id = tasks.project_id
LEFT JOIN conversations
ON conversations.id = tasks.conversation_id
GROUP BY
usage.request_id,
tasks.project_id,
projects.name,
tasks.conversation_id,
conversations.title,
usage.runtime,
usage.provider,
usage.model
ORDER BY MAX(usage.updated_at) DESC
LIMIT 500`
)
.all() as TokenUsageRecordRow[]
const records: TokenUsageRecord[] = rows.map((row) => ({
const systemSourceSql = `CASE
WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat'
WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision'
WHEN tasks.conversation_id LIKE 'knowledge:%' THEN 'knowledge'
WHEN tasks.conversation_id LIKE 'delegation:%' THEN 'delegation'
WHEN tasks.origin = 'assistant' AND tasks.conversation_id IS NULL
THEN 'magic-notes'
END`
const database = this.requireDatabase()
// Conversation and system rows are limited separately so frequent chat
// usage cannot hide background usage (or the reverse).
const selectRows = (system: boolean): TokenUsageRecordRow[] =>
database
.prepare(
`SELECT
usage.request_id,
tasks.project_id,
projects.name AS project_name,
tasks.conversation_id,
conversations.title AS conversation_title,
${systemSourceSql} AS system_source,
usage.runtime,
usage.provider,
usage.model,
COUNT(*) AS call_count,
SUM(usage.input_tokens) AS input_tokens,
SUM(usage.output_tokens) AS output_tokens,
SUM(usage.cache_read_tokens) AS cache_read_tokens,
SUM(usage.cache_write_tokens) AS cache_write_tokens,
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
) AS cache_input_tokens
FROM model_usage_calls usage
JOIN tasks ON tasks.id = usage.request_id
LEFT JOIN projects ON projects.id = tasks.project_id
LEFT JOIN conversations
ON conversations.id = tasks.conversation_id
WHERE (${systemSourceSql}) IS ${system ? 'NOT ' : ''}NULL
GROUP BY
usage.request_id,
tasks.project_id,
projects.name,
tasks.conversation_id,
conversations.title,
usage.runtime,
usage.provider,
usage.model
ORDER BY MAX(usage.updated_at) DESC
LIMIT 500`
)
.all() as TokenUsageRecordRow[]
const knownSources = new Set<string>(tokenUsageSystemSources)
const records: TokenUsageRecord[] = [
...selectRows(false),
...selectRows(true)
].map((row) => ({
requestId: row.request_id,
...(row.system_source && knownSources.has(row.system_source)
? { systemSource: row.system_source as TokenUsageSystemSource }
: {}),
projectId: row.project_id ?? undefined,
projectName: row.project_name ?? undefined,
conversationId: row.conversation_id ?? undefined,
@@ -5632,28 +5710,8 @@ export class AssistantDatabase {
cacheInput: row.cache_input_tokens,
totalTokens: row.input_tokens + row.output_tokens
}))
const totalRow = this.requireDatabase()
.prepare(
`SELECT
COUNT(*) AS call_count,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(
SUM(
input_tokens +
CASE
WHEN LOWER(provider) LIKE '%anthropic%'
THEN cache_read_tokens + cache_write_tokens
ELSE 0
END
),
0
) AS cache_input_tokens
FROM model_usage_calls`
)
.get() as {
type TotalRow = {
is_system: number | null
call_count: number
input_tokens: number
output_tokens: number
@@ -5661,16 +5719,51 @@ export class AssistantDatabase {
cache_write_tokens: number
cache_input_tokens: number
}
const totals = {
callCount: totalRow.call_count,
input: totalRow.input_tokens,
output: totalRow.output_tokens,
cacheRead: totalRow.cache_read_tokens,
cacheWrite: totalRow.cache_write_tokens,
cacheInput: totalRow.cache_input_tokens,
totalTokens: totalRow.input_tokens + totalRow.output_tokens
// Usage rows always reference a task (foreign key), so the join is lossless.
const totalRows = database
.prepare(
`SELECT
(${systemSourceSql}) IS NOT NULL AS is_system,
COUNT(*) AS call_count,
COALESCE(SUM(usage.input_tokens), 0) AS input_tokens,
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens,
COALESCE(
SUM(
usage.input_tokens +
CASE
WHEN LOWER(usage.provider) LIKE '%anthropic%'
THEN usage.cache_read_tokens + usage.cache_write_tokens
ELSE 0
END
),
0
) AS cache_input_tokens
FROM model_usage_calls usage
LEFT JOIN tasks ON tasks.id = usage.request_id
GROUP BY is_system`
)
.all() as TotalRow[]
const toTotals = (rows: TotalRow[]): TokenUsageTotals => {
const sum = (key: Exclude<keyof TotalRow, 'is_system'>): number =>
rows.reduce((total, row) => total + row[key], 0)
return {
callCount: sum('call_count'),
input: sum('input_tokens'),
output: sum('output_tokens'),
cacheRead: sum('cache_read_tokens'),
cacheWrite: sum('cache_write_tokens'),
cacheInput: sum('cache_input_tokens'),
totalTokens: sum('input_tokens') + sum('output_tokens')
}
}
return {
totals: toTotals(totalRows),
conversationTotals: toTotals(totalRows.filter((row) => !row.is_system)),
systemTotals: toTotals(totalRows.filter((row) => row.is_system)),
records
}
return { totals, records }
}
updateTaskStatus(
@@ -5776,9 +5869,7 @@ export class AssistantDatabase {
input.assistantMessageId
)
const requestRuntimeSelection =
input.runtimeSelection === undefined
? undefined
: agentRuntimeSelectionSchema.parse(input.runtimeSelection)
optionalAgentRuntimeSelectionSchema.parse(input.runtimeSelection)
const publicEvent = stripRemoteEventProvenance(input.event)
if (publicEvent.requestId !== input.taskId) {
throw new Error('远程事件的请求 ID 与任务不匹配')
@@ -5858,6 +5949,7 @@ export class AssistantDatabase {
createdAt:
metadata.createdAt ?? Date.parse(row.created_at),
status: metadata.status,
terminalStatus: metadata.terminalStatus,
reasoning: metadata.reasoning,
runtimeChecklist: metadata.runtimeChecklist,
blocks: metadata.blocks,
@@ -5914,17 +6006,16 @@ export class AssistantDatabase {
let contextState = parseConversationContextState(
conversation.context_state_json
)
const runtimeSelection =
requestRuntimeSelection ??
parseRuntimeSelection(conversation.runtime_selection_json) ??
parseRuntimeSelection(conversation.project_runtime_selection_json) ??
{ provider: 'auto' as const }
const runtimeSelectionKey = remoteEventSelectionKey(
requestRuntimeSelection,
parseRuntimeSelection(conversation.runtime_selection_json),
parseRuntimeSelection(conversation.project_runtime_selection_json)
)
if (publicEvent.type === 'context-metrics') {
contextState = {
...contextState,
contextMetrics: {
runtimeSelectionKey:
agentRuntimeSelectionKey(runtimeSelection),
runtimeSelectionKey,
contextTokens: publicEvent.contextTokens,
source: publicEvent.source,
basis: 'model-call'
@@ -5948,8 +6039,7 @@ export class AssistantDatabase {
estimatedAfterTokens !== undefined
? {
contextMetrics: {
runtimeSelectionKey:
agentRuntimeSelectionKey(runtimeSelection),
runtimeSelectionKey,
contextTokens: estimatedAfterTokens,
source: 'estimated' as const,
basis: 'conversation' as const
@@ -11471,6 +11561,13 @@ export class AssistantDatabase {
database.exec('PRAGMA user_version = 48; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
if (version.user_version < 49) {
database.exec('BEGIN IMMEDIATE')
try {
migrateRuntimeSelectionLayers(database)
database.exec('PRAGMA user_version = 49; COMMIT;')
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionReviewStore(): SupervisionReviewStore {
@@ -131,6 +131,24 @@ function fixture(content = 'Atlas and Beacon are separate projects.') {
return { db, request, respond, service }
}
it('records supervisor model usage under a hidden supervision task', async () => {
const f = fixture()
const run: AgentRuntime['run'] = async function* (input) {
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(f.respond(input.prompt)) }
yield { type: 'model-usage', requestId: input.requestId, callId: 'call-1', runtime: 'model', provider: 'openai',
model: 'gpt-review', inputTokens: 40, outputTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }
yield { type: 'done', requestId: input.requestId }
}
const service = createProductionSupervisorService(f.db, async () => ({ supervisorModelConcurrency: 1,
supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB: 1024 } }),
async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), new SupervisionModelPool())
await service.run(f.request)
const summary = f.db.getTokenUsageSummary()
expect(summary.records).toEqual([expect.objectContaining({ systemSource: 'supervision', model: 'gpt-review', input: 40, output: 8 })])
expect(summary.systemTotals).toMatchObject({ callCount: 1, input: 40 })
expect(f.db.listTasks()).toEqual([])
})
it('production factory normalizes two absent identities, then reuses exact candidate UUIDs without extra calls', async () => {
const f = fixture()
const first = await f.service().run(f.request)
+22 -5
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import { defaultSupervisionTimeoutSeconds, defaultSupervisorModelConcurrency } from '../../shared/application-settings-contracts'
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import type { AgentRuntime } from '../agent/runtime'
import type { AgentRuntime, RuntimeModelUsageEvent } from '../agent/runtime'
import type { AssistantDatabase } from './assistant-database'
import type { SupervisionModelPool } from './supervision-model-pool'
import { SupervisorService } from './supervisor-service'
@@ -11,7 +11,11 @@ export function createProductionSupervisorService(
database: AssistantDatabase,
getSettings: () => Promise<Partial<ApplicationSettings> | undefined>,
resolveRuntime: () => Promise<AgentRuntime>,
pool: SupervisionModelPool
pool: SupervisionModelPool,
persistUsage: (event: RuntimeModelUsageEvent) => void = event => database.upsertModelUsageCall({
requestId: event.requestId, callId: event.callId, runtime: event.runtime, provider: event.provider, model: event.model,
input: event.inputTokens, output: event.outputTokens, cacheRead: event.cacheReadTokens, cacheWrite: event.cacheWriteTokens
})
): SupervisorService {
return new SupervisorService({ collect: async () => { throw new Error('Paged review collector required') } }, {
summarize: async request => {
@@ -30,9 +34,13 @@ export function createProductionSupervisorService(
}, timeoutSeconds * 1000)
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
let output = '', completed = false
const conversationId = `supervision:${randomUUID()}`
const requestId = randomUUID()
const conversationId = `supervision:${requestId}`
// Hidden task row so model usage can be attributed to supervision.
database.createTask({ id: requestId, conversationId, title: '监督者回顾', instructions: '根据有界证据整理监督者回顾',
workMode: 'ask', origin: 'assistant', visible: false })
try {
for await (const event of runtime.run({ requestId: randomUUID(), conversationId, workMode: 'ask',
for await (const event of runtime.run({ requestId, conversationId, workMode: 'ask',
prompt: [request.systemInstruction, 'OUTPUT CONTRACT:', request.outputContract,
'REVIEW TIME RANGE:', JSON.stringify(request.request.timeRange), 'KNOWN ENTITIES:', JSON.stringify(request.candidates.map((candidate, index) => ({
candidateRef: `known_${index + 1}`, label: candidate.label, description: candidate.description
@@ -48,14 +56,23 @@ export function createProductionSupervisorService(
modelSignal.throwIfAborted()
}
}
if (event.type === 'model-usage') persistUsage(event)
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
if (event.type === 'error') throw new Error(event.message)
if (event.type === 'done') completed = true
}
modelSignal.throwIfAborted()
if (!completed) throw new Error('监督者模型未报告完成')
database.updateTaskStatus(requestId, 'completed')
return output
} catch (error) { throw timeoutError ?? error }
} catch (error) {
const failure = timeoutError ?? error
try {
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
} catch { /* status bookkeeping must not mask the model failure */ }
throw failure
}
finally { clearTimeout(timeout); await runtime.releaseConversation?.(conversationId) }
}, request.signal)
}
@@ -2,13 +2,147 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { describe, expect, it } from 'vitest'
import { DatabaseSync, StatementSync } from 'node:sqlite'
import { describe, expect, it, vi } from 'vitest'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
import { defaultDocumentParsingSettings } from './document-parsing-settings-store'
describe('conversation attachment files and references', () => {
it.each([false, true])('measures repeated references with an attachment: %s', async (populated) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const storage = new ConversationAttachmentStorage(root, results)
try {
const conversation = randomUUID(), id = randomUUID()
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
if (populated) storage.save(context, JSON.stringify(context), Buffer.from('source'))
const ids = populated ? [id] : []
storage.reference(conversation, 'draft', conversation, ids)
const get = vi.spyOn(StatementSync.prototype, 'get')
const all = vi.spyOn(StatementSync.prototype, 'all')
const run = vi.spyOn(StatementSync.prototype, 'run')
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
let counts: { selects: number; writes: number; transactions: number }
try {
for (let index = 0; index < 100; index++) storage.reference(conversation, 'draft', conversation, ids)
counts = {
selects: get.mock.calls.length + all.mock.calls.length,
writes: run.mock.calls.length,
transactions: exec.mock.calls.filter(([sql]) => sql === 'BEGIN IMMEDIATE').length
}
} finally { get.mockRestore(); all.mockRestore(); run.mockRestore(); exec.mockRestore() }
const durations: number[] = []
for (let sample = 0; sample < 3; sample++) {
const start = performance.now()
for (let index = 0; index < 1000; index++) storage.reference(conversation, 'draft', conversation, ids)
durations.push(Number((performance.now() - start).toFixed(3)))
}
console.info(JSON.stringify({ populated, countedCalls: 100, ...counts, callsPerSample: 1000, durationsMs: durations }))
expect(counts.writes).toBe(0)
expect(counts.transactions).toBe(0)
expect(storage.draft(conversation)).toEqual(populated ? [context] : [])
} finally { storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
it.each(['draft', 'message', 'queue', 'parsing'] as const)('preserves ordered replacement, ownership and cleanup for %s references', async (kind) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const storage = new ConversationAttachmentStorage(root, results)
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
try {
const conversation = randomUUID(), otherConversation = randomUUID(), owner = conversation
const ids = [randomUUID(), randomUUID(), randomUUID()] as const
for (const id of ids) {
const context = { id, name: 'source.txt', kind: 'text' as const, size: 6, preview: 'source' }
storage.save(context, JSON.stringify(context), Buffer.from(id))
}
const refs = () => database.prepare('SELECT attachment_id, conversation_id FROM attachment_refs WHERE kind = ? AND owner_id = ? ORDER BY rowid').all(kind, owner)
const expected = (ordered: string[], conversationId = conversation) => ordered.map((id) => ({ attachment_id: id, conversation_id: conversationId }))
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1]])
expect(refs()).toEqual(expected(ids.slice(0, 2)))
expect(storage.original(ids[0]).path).toContain(join('conversation-assets', conversation, 'documents', ids[0]))
await expect(readFile(join(root, 'temp', 'document-parsing', ids[0], 'original.txt'))).rejects.toThrow()
const get = vi.spyOn(StatementSync.prototype, 'get')
const run = vi.spyOn(StatementSync.prototype, 'run')
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
try {
storage.reference(conversation, kind, owner, [ids[0], 'missing', ids[0], ids[1], 'missing'])
expect(get.mock.calls.map(([id]) => id)).toEqual([ids[0], 'missing', ids[1]])
expect(run).not.toHaveBeenCalled()
expect(exec).not.toHaveBeenCalled()
} finally { get.mockRestore(); run.mockRestore(); exec.mockRestore() }
storage.reference(conversation, kind, owner, [ids[1], ids[0]])
expect(refs()).toEqual(expected([ids[1], ids[0]]))
if (kind === 'draft') expect(storage.draft(owner).map(({ id }) => id)).toEqual([ids[1], ids[0]])
storage.reference(otherConversation, kind, owner, [ids[1], ids[0]])
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
storage.deleteConversation(conversation, ids)
expect(refs()).toEqual(expected([ids[1], ids[0]], otherConversation))
storage.reference(otherConversation, 'message', 'shared-owner', [ids[0]])
storage.reference(otherConversation, kind, owner, [ids[2]])
expect(refs()).toEqual(expected([ids[2]], otherConversation))
storage.collect()
expect(storage.has(ids[0])).toBe(true)
expect(storage.has(ids[1])).toBe(false)
expect(storage.original(ids[2]).data).toEqual(Buffer.from(ids[2]))
storage.reference(otherConversation, kind, owner, ['missing'])
expect(refs()).toEqual([])
const emptyRun = vi.spyOn(StatementSync.prototype, 'run')
const emptyExec = vi.spyOn(DatabaseSync.prototype, 'exec')
try {
storage.reference(otherConversation, kind, owner, [])
storage.reference(otherConversation, kind, owner, ['missing', 'missing'])
expect(emptyRun).not.toHaveBeenCalled()
expect(emptyExec).not.toHaveBeenCalled()
} finally { emptyRun.mockRestore(); emptyExec.mockRestore() }
storage.collect()
expect(storage.has(ids[2])).toBe(false)
storage.deleteConversation(otherConversation)
expect(storage.has(ids[0])).toBe(false)
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
it('promotes temporary files even when references already match and rolls back failed replacements', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const storage = new ConversationAttachmentStorage(root, results)
const database = new DatabaseSync(join(root, 'conversation-attachments.sqlite'))
try {
const conversation = randomUUID(), first = randomUUID(), second = randomUUID()
for (const id of [first, second]) {
const context = { id, name: 'image.png', kind: 'image' as const, size: 6, preview: 'source' }
storage.save(context, JSON.stringify(context), Buffer.from(id))
}
database.prepare('INSERT INTO attachment_refs VALUES (?, ?, ?, ?)').run(first, conversation, 'draft', conversation)
database.exec(`CREATE TRIGGER reject_promotion BEFORE UPDATE OF path ON attachments
BEGIN SELECT RAISE(ABORT, 'promotion failed'); END`)
expect(() => storage.reference(conversation, 'draft', conversation, [first])).toThrow('promotion failed')
expect(storage.original(first).path).toContain(join('temp', 'document-parsing', first))
expect(storage.original(first).data).toEqual(Buffer.from(first))
database.exec('DROP TRIGGER reject_promotion')
storage.reference(conversation, 'draft', conversation, [first, first])
expect(storage.original(first).path).toContain(join('conversation-assets', conversation, 'images', first))
expect(storage.original(first).data).toEqual(Buffer.from(first))
await expect(readFile(join(root, 'temp', 'document-parsing', first, 'original.png'))).rejects.toThrow()
database.exec(`CREATE TRIGGER reject_reference BEFORE INSERT ON attachment_refs
WHEN NEW.attachment_id = '${second}' BEGIN SELECT RAISE(ABORT, 'reference failed'); END`)
expect(() => storage.reference(conversation, 'draft', conversation, [first, second])).toThrow('reference failed')
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([first])
// Promotion precedes the reference transaction and remains durable after its rollback.
expect(storage.original(second).path).toContain(join('conversation-assets', conversation, 'images', second))
expect(storage.original(second).data).toEqual(Buffer.from(second))
database.exec('DROP TRIGGER reject_reference')
storage.reference(conversation, 'draft', conversation, [second, first])
expect(storage.draft(conversation).map(({ id }) => id)).toEqual([second, first])
expect(database.prepare('PRAGMA integrity_check').get()).toMatchObject({ integrity_check: 'ok' })
expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([])
} finally { database.close(); storage.close(); await results.close(); await rm(root, { recursive: true, force: true }) }
})
it('retains interrupted originals without turning them into sendable draft attachments', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-attachment-'))
let results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
+11 -1
View File
@@ -117,7 +117,17 @@ export class ConversationAttachmentStorage {
}
reference(conversationId: string, kind: 'draft' | 'message' | 'queue' | 'parsing', ownerId: string, ids: string[]): void {
const assets = ids.filter((id) => this.has(id)).map((id) => this.row(id))
const assets: AssetRow[] = []
for (const id of new Set(ids)) {
const asset = this.database.prepare('SELECT * FROM attachments WHERE id = ?').get(id) as AssetRow | undefined
if (asset) assets.push(asset)
}
const refs = this.database.prepare(`SELECT attachment_id, conversation_id FROM attachment_refs
WHERE kind = ? AND owner_id = ? ORDER BY rowid`).all(kind, ownerId) as { attachment_id: string; conversation_id: string }[]
// Draft order is visible, and matching references can still point to temporary files.
if (refs.length === assets.length && assets.every((asset, index) =>
refs[index]?.attachment_id === asset.id && refs[index]?.conversation_id === conversationId && !asset.path.startsWith('temp')
)) return
for (const asset of assets) {
if (!asset.path.startsWith(`temp`)) continue
const context = JSON.parse(asset.metadata) as ContextAttachment
+61 -9
View File
@@ -32,6 +32,7 @@ import {
applyRuntimeSelection,
getConfiguredRuntimeTarget,
resolveConfiguredAgentRuntimeSelection,
resolveLayeredRuntimeSelection,
type SelectedRuntimeTarget
} from './agent/runtime-selection'
import { CapabilityService } from './capabilities/capability-service'
@@ -86,6 +87,7 @@ import {
import { FeedbackService } from './feedback/feedback-service'
import { registerFeedbackIpcHandler } from './feedback/feedback-ipc'
import type { AgentRuntimeSelection } from '../shared/runtime-selection-contracts'
import type { SystemModelUsageInput } from '../shared/assistant-contracts'
import {
runCleanupBeforeDeadline,
settleCleanupPhases
@@ -237,6 +239,28 @@ let managedRemoteExecutionServices:
let directModelSubagentScheduler: SubagentScheduler | undefined
let localToolEnvironmentService: LocalToolEnvironmentService | undefined
function recordKnowledgeUsage(
bucket: SystemModelUsageInput['bucket'],
usage: Omit<SystemModelUsageInput, 'source' | 'bucket'>
): void {
try {
assistantDatabase?.recordSystemModelUsage({
...usage,
source: 'knowledge',
bucket
})
} catch (error) {
void desktopDiagnostics
.recordFailure({
component: 'desktop',
stage: 'knowledge',
code: 'knowledge.usage.persist-failed',
error
})
.catch(() => undefined)
}
}
type ManagedEmbeddingProvider = EmbeddingProvider & {
dispose?: () => void | Promise<void>
}
@@ -259,7 +283,17 @@ async function createEmbeddingProvider(
return new OpenAIEmbeddingClient({
endpoint: connection.baseUrl,
model: connection.modelName,
apiKey: connection.apiKey
apiKey: connection.apiKey,
onUsage: ({ model, inputTokens }) =>
recordKnowledgeUsage('embedding', {
runtime: 'embedding',
provider: 'openai-compatible',
model,
input: inputTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0
})
})
}
const snapshot = await manager.getSnapshot()
@@ -676,11 +710,13 @@ if (hasSingleInstanceLock) {
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
packaged: app.isPackaged,
resolveRuntimeSelection: async (selection) =>
resolveConfiguredAgentRuntimeSelection(
await settingsStore.getResolvedSettings(),
selection
),
resolveRuntimeSelection: async (layer) => {
const settings = await settingsStore.getResolvedSettings()
return resolveConfiguredAgentRuntimeSelection(
settings,
resolveLayeredRuntimeSelection(settings, { project: layer }, { remote: true }).selection
)
},
resolveModelProfile: async (selection) => {
const resolved = applyRuntimeSelection(
await settingsStore.getResolvedSettings(),
@@ -899,7 +935,16 @@ if (hasSingleInstanceLock) {
credentialCipher: secureCipher,
databasePath: join(app.getPath('userData'), 'knowledge.sqlite'),
managedRoot: join(app.getPath('userData'), 'knowledge'),
extractStructured: createModelGraphExtractor(settingsStore),
extractStructured: createModelGraphExtractor(
settingsStore,
fetch,
undefined,
(usage) =>
recordKnowledgeUsage('graph-extraction', {
runtime: 'model',
...usage
})
),
parseDocument: documentParsingService.parse
})
knowledgeService = startupKnowledgeService
@@ -935,6 +980,15 @@ if (hasSingleInstanceLock) {
)
}
})
},
onModelUsageChanged: () => {
if (
mainWindow &&
!mainWindow.isDestroyed() &&
!mainWindow.webContents.isDestroyed()
) {
mainWindow.webContents.send(ipcChannels.tokenUsageChanged)
}
}
}
)
@@ -1446,8 +1500,6 @@ if (hasSingleInstanceLock) {
(await capabilityService.getEnabledBuiltinMcpServerIds(runtimeTarget)).includes('story-graph'),
read: (name, input, projectId, signal) => startupAssistantDatabase.readStoryGraph(name, input, projectId, signal)
},
// This dedicated gateway lives until native-client disposal, not a chat request timeout.
now: () => 0,
magicNotesDatabase: startupAssistantDatabase, configService: goodbuddyConfigService,
obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider
})
+277 -32
View File
@@ -33,6 +33,8 @@ import type {
} from '../shared/ssh-host-contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
import { ImageGenerationService } from './agent/image-generation-service'
import { ApplicationSettingsStore } from './application-settings-store'
import * as desktopNotification from './desktop-notification'
@@ -518,7 +520,8 @@ describe('registerIpcHandlers computer capabilities', () => {
let browserStateListener:
| ((state: BrowserLiveState) => void)
| undefined
const shortcutSnapshot = {
const shortcutSnapshot: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = {
platform: 'win32',
settings: {
enabled: true,
accelerator: 'Control+Alt+K'
@@ -607,6 +610,18 @@ describe('registerIpcHandlers computer capabilities', () => {
shortcut: 'Ctrl + Alt + K',
shortcutStatus: 'registered'
})
for (const status of ['disabled', 'conflict', 'failed'] as const) {
shortcutSettingsService.getSnapshot.mockReturnValueOnce({
...shortcutSnapshot,
registered: false,
registeredAccelerator: undefined,
status
})
expect(electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({
shortcut: '',
shortcutStatus: status
})
}
expect(
electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
event
@@ -4521,6 +4536,167 @@ describe('registerIpcHandlers local conversation persistence', () => {
vi.clearAllMocks()
})
it.each(['current', 'profile', 'image', 'missing'] as const)(
'routes direct model manual compaction using %s selection', async (source) => {
const profile = {
id: crypto.randomUUID(), name: 'Selected', baseUrl: 'https://models.example',
modelName: 'selected-text', protocol: 'anthropic-messages', authentication: 'none'
}
const fallback = { ...profile, id: crypto.randomUUID(), modelName: 'default-text' }
const summary = { ...profile, id: crypto.randomUUID(), modelName: 'summary-text' }
const selection = { provider: 'model' as const, profileId: source === 'missing' ? crypto.randomUUID() : profile.id }
const settings = {
provider: 'model', workspacePath: 'C:\\Workspace', defaultModelProfileId: fallback.id,
modelProfiles: [fallback, { ...profile, protocol: source === 'image' ? 'openai-images-generations' : profile.protocol }, summary],
continueModelProfile: fallback,
contextCompression: { enabled: false, modelSource: source === 'profile' ? { kind: 'profile', profileId: summary.id } : { kind: 'current' } }
}
const messages = [
{ id: crypto.randomUUID(), role: 'user', content: 'earlier question', state: 'complete' },
{ id: crypto.randomUUID(), role: 'assistant', content: 'earlier answer', state: 'complete' }
]
const conversationId = crypto.randomUUID()
const database = {
queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => []),
// Stored conversations hold a layer; the renderer sends the resolved selection.
getConversation: vi.fn(() => ({ id: conversationId, runtimeSelection: {
provider: 'model', model: { kind: 'profile', profileId: selection.profileId }
}, messages })),
createTask: vi.fn(), updateTaskStatus: vi.fn()
}
const compactor = {
compactConversation: vi.fn(async () => ({ result: {
provider: 'model', strategy: 'goodbuddy-summary', compacted: true, detail: 'Compacted'
} })),
dispose: vi.fn(async () => {})
}
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(compactor)
const webContents = { on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn()
}
const window = { webContents, isDestroyed: vi.fn(() => false), on: vi.fn(), removeListener: vi.fn() }
const dispose = registerIpcHandlers(
window as never, {} as never, 'CommandOrControl+Shift+Space',
{ getResolvedSettings: vi.fn(async () => settings) } as never,
{} as never, { clear: vi.fn() } as never, {} as never, database as never,
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
)
try {
const result = electronMocks.handlers.get(ipcChannels.agentCompactConversation)?.(
{ sender: webContents, senderFrame: webContents.mainFrame },
{ requestId: crypto.randomUUID(), conversationId, runtimeSelection: selection,
history: messages.map(({ role, content }) => ({ role, content })),
historyMessageIds: messages.map(({ id }) => id) }
)
if (source === 'image' || source === 'missing') {
await expect(result).rejects.toThrow()
expect(runtimeFactoryMocks.createModelProfileRuntime).not.toHaveBeenCalled()
} else {
await expect(result).resolves.toMatchObject({ provider: 'model', compacted: true })
expect(runtimeFactoryMocks.createModelProfileRuntime).toHaveBeenCalledWith(
'C:\\Workspace', expect.objectContaining({ defaultModelProfileId: profile.id }),
source === 'profile' ? summary : profile
)
expect(compactor.dispose).toHaveBeenCalledOnce()
expect(database.updateTaskStatus).toHaveBeenCalledWith(expect.any(String), 'completed')
}
} finally {
await dispose()
}
}
)
it('pins and unpins without attachment reconciliation or import checks', async () => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-pin-ipc-'))
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
const results = new DocumentResultStorage(join(root, 'temp', 'document-parsing'))
const assets = new ConversationAttachmentStorage(root, results)
let dispose: ReturnType<typeof registerIpcHandlers> | undefined
try {
database.initialize(root)
const conversationId = '00000000-0000-4000-8000-000000000301'
const messageId = '00000000-0000-4000-8000-000000000302'
database.saveLocalConversations([{
header: { id: conversationId, title: 'Pin regression', updatedAt: 1 },
messages: [{ id: messageId, role: 'user', content: 'Keep this message', createdAt: 1, state: 'complete' }]
}])
const assetId = assets.beginParsing(conversationId, 'pending.pdf', Buffer.from('original'))
assets.reference(conversationId, 'draft', conversationId, [assetId])
assets.reference(conversationId, 'message', messageId, [assetId])
const before = database.getConversation(conversationId)
const reconcile = vi.spyOn(assets, 'reconcile')
const collect = vi.spyOn(assets, 'collect')
const hasOwner = vi.spyOn(database, 'hasAttachmentOwner')
const contextManager = {
assets,
activeContextIds: vi.fn(() => []),
cancelUnavailableImport: vi.fn(),
clear: vi.fn()
}
const webContents = {
on: vi.fn(), removeListener: vi.fn(),
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
send: vi.fn()
}
const window = {
webContents, isDestroyed: vi.fn(() => false),
on: vi.fn(), removeListener: vi.fn()
}
dispose = registerIpcHandlers(
window as never, { capability: 'text' } as never,
'CommandOrControl+Shift+Space', {} as never, {} as never,
contextManager as never, {} as never, database,
{ clear: vi.fn() } as never, {} as never, vi.fn(async () => {})
)
const event = { sender: webContents, senderFrame: webContents.mainFrame }
const pin = electronMocks.handlers.get(ipcChannels.conversationsSetPinned)!
expect(pin).toBeTypeOf('function')
webContents.send.mockClear()
for (const pinned of [true, true, false]) {
expect(pin(event, { conversationId, pinned })).toBeUndefined()
expect(database.getConversation(conversationId)).toEqual({ ...before, pinned })
}
expect(webContents.send.mock.calls).toEqual([
[ipcChannels.conversationsChanged],
[ipcChannels.conversationsChanged],
[ipcChannels.conversationsChanged]
])
expect({
reconciliations: reconcile.mock.calls.length,
ownerChecks: hasOwner.mock.calls.length,
collections: collect.mock.calls.length,
activeContextReads: contextManager.activeContextIds.mock.calls.length,
importChecks: contextManager.cancelUnavailableImport.mock.calls.length
}).toEqual({ reconciliations: 0, ownerChecks: 0, collections: 0, activeContextReads: 0, importChecks: 0 })
expect(assets.draft(conversationId).map((asset) => asset.id)).toEqual([assetId])
expect(assets.get(assetId).parsingState).toBe('parsing')
expect(assets.original(assetId).data).toEqual(Buffer.from('original'))
webContents.send.mockClear()
expect(() => pin(event, { conversationId, pinned: 'yes' })).toThrow()
expect(() => pin({ ...event, sender: {} }, { conversationId, pinned: true })).toThrow()
expect(() => pin(event, {
conversationId: '00000000-0000-4000-8000-000000000399', pinned: true
})).toThrow()
expect(webContents.send).not.toHaveBeenCalled()
expect(database.getConversation(conversationId)).toEqual(before)
expect(electronMocks.handlers.get(ipcChannels.conversationsDeleteLocal)!(event, conversationId)).toBe(true)
expect(collect).toHaveBeenCalledOnce()
expect(contextManager.cancelUnavailableImport).toHaveBeenCalledOnce()
expect(assets.has(assetId)).toBe(false)
} finally {
await dispose?.()
assets.close()
await results.close()
database.close()
await rm(root, { recursive: true, force: true })
}
})
it('validates and forwards incremental saves, branches, and explicit deletions', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000301'
@@ -4541,7 +4717,7 @@ describe('registerIpcHandlers local conversation persistence', () => {
JSON.stringify({
input: {
conversationId,
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
workMode: 'ask',
includeMemoryContext: true,
prompt: queuedItem.label,
@@ -5056,7 +5232,8 @@ describe('registerIpcHandlers Runtime customization', () => {
requestId,
conversationId,
projectId,
runtimeSelection: { provider: 'opencode' as const },
// The renderer sends the resolved selection; OpenCode here uses its own configuration.
runtimeSelection: { provider: 'opencode' as const, runtimeConfig: true as const },
history: messages.map(({ role, content }) => ({
role,
content
@@ -5256,7 +5433,7 @@ describe('registerIpcHandlers agent terminal state', () => {
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' }
},
kind: 'channel',
channel: 'wecom',
@@ -5623,6 +5800,9 @@ describe('registerIpcHandlers agent terminal state', () => {
const regenerated = await regenerate(event, { conversationId, operationId: original.id }) as typeof original
expect(regenerated.id).not.toBe(original.id)
expect(regenerated.messageId).toBe(messageId)
// Regeneration keeps the originating task so its usage satisfies the task foreign key.
expect(regenerated.requestId).toBe(original.requestId)
expect(regenerated.callId).not.toBe(original.callId)
expect(fetcher).toHaveBeenCalledTimes(2)
database.saveLocalConversations([{ header: { ...header, workMode: 'ask' }, messages: [] }])
await expect(regenerate(event, { conversationId, operationId: original.id })).rejects.toThrow('Execute')
@@ -7140,8 +7320,18 @@ describe('registerIpcHandlers agent terminal state', () => {
}
}
const { harness, projectId } = createManagedSshHarness(selectedRuntime)
harness.getResolvedSettings.mockResolvedValue({
provider: 'opencode', defaultModelProfileId: selection.profileId, opencodeBaseUrl: '',
modelProfiles: [{
id: selection.profileId, name: 'Remote', modelName: 'remote', baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
const project = harness.assistantDatabase.getProject(projectId)
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: selection })
harness.assistantDatabase.getProject.mockReturnValue({
...project,
runtimeSelection: { provider: 'opencode', model: { kind: 'profile', profileId: selection.profileId } }
})
harness.assistantDatabase.getConversation.mockReturnValue({
id: conversationId, projectId, messages: []
})
@@ -8219,7 +8409,7 @@ describe('registerIpcHandlers agent terminal state', () => {
{
requestId: '00000000-0000-4000-8000-000000000728',
conversationId,
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
prompt: '等待 Runtime',
workMode: 'ask',
knowledgeLibraryIds: []
@@ -8233,7 +8423,7 @@ describe('registerIpcHandlers agent terminal state', () => {
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000729',
conversationId,
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
prompt: '不能并发',
workMode: 'ask',
knowledgeLibraryIds: []
@@ -8292,7 +8482,7 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
runtimeSelection: { provider: 'model' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: item.label,
@@ -8345,7 +8535,7 @@ describe('registerIpcHandlers agent terminal state', () => {
try {
await expect(electronMocks.handlers.get(ipcChannels.conversationQueueEnqueueUser)?.(trustedEvent(harness.webContents), {
conversationId: '00000000-0000-4000-8000-000000000731',
runtimeSelection: { provider: 'auto' }, workMode: 'ask', prompt: 'Read the picture',
workMode: 'ask', prompt: 'Read the picture',
attachments: [{ id: '00000000-0000-4000-8000-000000000734', name: 'image.png', size: 10, preview: '', kind: 'image' }],
knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto'
})).rejects.toThrow('图片输入')
@@ -8374,7 +8564,7 @@ describe('registerIpcHandlers agent terminal state', () => {
'00000000-0000-4000-8000-000000000734'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
runtimeSelection: { provider: 'model' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '排队发送',
@@ -8479,7 +8669,7 @@ describe('registerIpcHandlers agent terminal state', () => {
const itemId = '00000000-0000-4000-8000-000000000746'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
runtimeSelection: { provider: 'model' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '需要重新排队',
@@ -8545,7 +8735,7 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
prompt: '失败后的新消息',
workMode: 'ask',
knowledgeLibraryIds: []
@@ -8574,7 +8764,7 @@ describe('registerIpcHandlers agent terminal state', () => {
const createItem = (id: string, prompt: string) => {
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
runtimeSelection: { provider: 'model' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt,
@@ -8919,7 +9109,7 @@ describe('registerIpcHandlers agent terminal state', () => {
defaultModelProfileId: profileId, deepseekHarnessModelSource: { kind: 'profile', profileId }
}))
const project = database.createProject({ name: 'Native UI', description: '', rootPath: root,
defaultWorkMode: 'ask', runtimeSelection: { provider: 'deepseek-harness', profileId } })
defaultWorkMode: 'ask', runtimeSelection: { provider: 'deepseek-harness', model: { kind: 'profile', profileId } } })
const conversationId = '00000000-0000-4000-8000-000000000222'
const now = Date.now()
const save = async (): Promise<string> => {
@@ -8950,7 +9140,7 @@ describe('registerIpcHandlers agent terminal state', () => {
rootDirectory: join(root, 'clients'), managedNodeDirectory: join(root, 'node'),
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
npmCliPath: resolveNpmCliPaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }).npmCliPath,
createGateway: () => new KnowledgeMcpGateway({} as never, { now: () => 0 }),
createGateway: () => new KnowledgeMcpGateway({} as never),
openExternal: async (url) => { opened.push(url) }
})
harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
@@ -9007,7 +9197,7 @@ describe('registerIpcHandlers agent terminal state', () => {
[`${provider}ModelSource`]: { kind: 'profile', profileId }
}))
}
database.updateProject(project.id, { ...project, runtimeSelection: { provider, profileId } })
database.updateProject(project.id, { ...project, runtimeSelection: { provider, model: { kind: 'profile', profileId } } })
const onTerminal = vi.fn<(value: import('../shared/terminal-contracts').TerminalSnapshot) => void>()
notify.mockClear()
view.rerender(createElement(RuntimeNativeClientActions, { browser: false, contextKey: `${project.id}:${provider}`,
@@ -10485,8 +10675,10 @@ describe('registerIpcHandlers agent terminal state', () => {
).resolves.toEqual(
expect.objectContaining({ label: 'model-two' })
)
// Resolved selections name the model source explicitly.
expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({
provider: 'opencode'
provider: 'opencode',
runtimeConfig: true
})
expect(fallbackRuntime.getStatus).not.toHaveBeenCalled()
@@ -10550,21 +10742,36 @@ describe('registerIpcHandlers agent terminal state', () => {
}
const harness = createHarness(requestRuntime, undefined, 'always', undefined, false, selectedRuntimes)
const projectId = '00000000-0000-4000-8000-000000000101'
const fixed = { provider: 'continue', profileId: '00000000-0000-4000-8000-000000000042' }
const fixedProfileId = '00000000-0000-4000-8000-000000000042'
const project = { id: projectId, rootPath: 'C:\\ProjectWorkspace' }
harness.getResolvedSettings.mockResolvedValue({
provider: 'model', defaultModelProfileId: fixedProfileId, opencodeBaseUrl: '',
modelProfiles: [{
id: fixedProfileId, name: 'Fixed', modelName: 'fixed', baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: { provider: 'opencode' } })
const event = trustedEvent(harness.webContents)
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-one',
requestId: '00000000-0000-4000-8000-000000000011', prompt: 'one', workMode: 'ask'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith({ provider: 'opencode' }, expect.anything())
harness.assistantDatabase.getProject.mockReturnValue({ ...project, runtimeSelection: fixed })
// No project model: OpenCode uses its global default (own configuration here).
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
{ provider: 'opencode', runtimeConfig: true }, expect.anything()
)
harness.assistantDatabase.getProject.mockReturnValue({
...project,
runtimeSelection: { provider: 'continue', model: { kind: 'profile', profileId: fixedProfileId } }
})
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-two',
requestId: '00000000-0000-4000-8000-000000000012', prompt: 'two', workMode: 'ask'
})
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(fixed, expect.anything())
expect(selectedRuntimes.getRuntime).toHaveBeenLastCalledWith(
{ provider: 'continue', profileId: fixedProfileId }, expect.anything()
)
await harness.handler?.(event, {
projectId, conversationId: 'project-rule-three', runtimeSelection: { provider: 'model' },
requestId: '00000000-0000-4000-8000-000000000013', prompt: 'three', workMode: 'ask'
@@ -11185,6 +11392,47 @@ describe('registerIpcHandlers agent terminal state', () => {
}
)
it('sends the work-mode instruction only once to runtimes that consume trusted instructions', async () => {
let receivedRequest:
| { requestId: string; prompt: string; trustedInstructions?: string }
| undefined
const runtime = {
runtimeId: 'opencode',
capability: 'chat',
requiresToolApproval: false,
supportsToolExecution: true,
consumesTrustedInstructions: true,
getStatus: vi.fn(),
dispose: vi.fn(),
async *run(request: {
requestId: string
prompt: string
trustedInstructions?: string
}) {
receivedRequest = request
yield { requestId: request.requestId, type: 'done' }
}
}
const harness = createHarness(runtime)
const requestId = '5c3e1f7a-2b8d-4e6f-9a1c-7d2b4e6f8a10'
harness.handler?.(trustedEvent(harness.webContents), {
requestId,
conversationId: 'conversation-1',
prompt: 'fix the bug',
workMode: 'execute'
})
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(requestId, 'completed')
)
expect(receivedRequest?.prompt).toBe('fix the bug')
expect(receivedRequest?.trustedInstructions).toContain('Work mode: Execute.')
await harness.dispose()
})
it('routes eligible Ask requests through the persisted smart expert service and publishes child events', async () => {
const runtime = {
capability: 'chat',
@@ -11586,7 +11834,7 @@ describe('registerIpcHandlers agent terminal state', () => {
defaultWorkMode: 'ask',
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000001' }
},
kind: 'channel',
channel,
@@ -12014,15 +12262,11 @@ describe('registerIpcHandlers agent terminal state', () => {
expect.any(String),
'waiting_approval'
)
// Channel conversations inherit the project's choice instead of copying it.
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.objectContaining({
runtimeSelection: {
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
}
})
expect.not.objectContaining({ runtimeSelection: expect.anything() })
)
await harness.dispose()
})
@@ -12344,7 +12588,10 @@ describe('registerIpcHandlers agent terminal state', () => {
toolApproval: 'always',
subagentSmartRoutingEnabled: false,
continueModelProfile: { id: configuredProfileId },
modelProfiles: [{ id: configuredProfileId }]
modelProfiles: [{
id: configuredProfileId, baseUrl: 'https://models.example/v1',
protocol: 'openai-chat-completions', authentication: 'none'
}]
})
vi.mocked(
harness.assistantDatabase.listProjects
@@ -12400,9 +12647,7 @@ describe('registerIpcHandlers agent terminal state', () => {
expect(
harness.assistantDatabase.getOrCreateRemoteConversation
).toHaveBeenCalledWith(
expect.objectContaining({
runtimeSelection: { provider: 'continue' }
})
expect.not.objectContaining({ runtimeSelection: expect.anything() })
)
expect(receivedAuthorize).toBeUndefined()
expect(harness.approvalBroker.request).not.toHaveBeenCalled()
+90 -51
View File
@@ -121,9 +121,10 @@ import {
} from '../shared/document-parsing-contracts'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
getDefaultRuntimeSelection,
type AgentRuntimeSelection
optionalAgentRuntimeSelectionSchema,
withoutLegacyAutoSelection,
type AgentRuntimeSelection,
type RuntimeSelectionLayer
} from '../shared/runtime-selection-contracts'
import { registerMagicNotesAnalysisIpcHandlers, registerMagicTodosAnalysisIpcHandlers } from './magic-notes/magic-notes-analysis-ipc'
import { registerMagicNotesIpcHandlers, registerMagicTodosIpcHandlers } from './magic-notes/magic-notes-ipc'
@@ -146,6 +147,7 @@ import {
expertCreateSchema,
type AssistantSchedule,
type AssistantArtifact,
type AssistantProject,
type ConversationAttachment
} from '../shared/assistant-contracts'
import {
@@ -168,7 +170,8 @@ import {
} from './agent/create-runtime'
import {
applyRuntimeSelection,
resolveConfiguredAgentRuntimeSelection
resolveConfiguredAgentRuntimeSelection,
resolveLayeredRuntimeSelection
} from './agent/runtime-selection'
import { safeToolErrorDetail } from './agent/approval-summary'
import { ReasoningTagStreamParser } from './agent/reasoning-stream'
@@ -1177,6 +1180,24 @@ export function registerIpcHandlers(
)
}
})
/** Resolves stored project and conversation layers against current settings. */
const resolveStoredRuntimeSelection = async (
input: {
projectId?: string
/** Callers that already loaded the project pass it to avoid a second read. */
project?: Pick<AssistantProject, 'runtimeSelection' | 'executionSpace'>
conversationLayer?: RuntimeSelectionLayer
}
): Promise<AgentRuntimeSelection> => {
const project =
input.project ??
(input.projectId ? assistantDatabase.getProject(input.projectId) : undefined)
return resolveLayeredRuntimeSelection(
await settingsStore.getResolvedSettings(),
{ project: project?.runtimeSelection, conversation: input.conversationLayer },
{ remote: project?.executionSpace?.kind === 'ssh' }
).selection
}
const resolveRequestRuntime = async (
request: Pick<
AgentRequest,
@@ -1200,14 +1221,15 @@ export function registerIpcHandlers(
if (executionSpace?.kind === 'ssh' && !selectedRuntimes) {
throw new Error(REMOTE_EXECUTION_SPACE_UNAVAILABLE)
}
let selection = request.runtimeSelection ?? project?.runtimeSelection
if (
!selectedRuntimes ||
(!selection && !executionSpace)
(!request.runtimeSelection && !project?.runtimeSelection && !executionSpace)
) {
return runtime
}
selection ??= { provider: 'auto' }
let selection =
request.runtimeSelection ??
(await resolveStoredRuntimeSelection({ projectId: project?.id }))
if (request.followConfiguredAgentRuntime) {
selection = resolveConfiguredAgentRuntimeSelection(
await settingsStore.getResolvedSettings(),
@@ -1221,8 +1243,8 @@ export function registerIpcHandlers(
if (selected.capability === 'image-generation') return
const target = runtimeTargetFor(selected)
const configured = await settingsStore.getResolvedSettings()
const projectSelection = request.projectId ? assistantDatabase.getProject(request.projectId).runtimeSelection : undefined
const selection = request.runtimeSelection ?? projectSelection
const selection = request.runtimeSelection ??
(request.projectId ? await resolveStoredRuntimeSelection({ projectId: request.projectId }) : undefined)
const effective = selection ? applyRuntimeSelection(configured, selection).settings : configured
const supported = target === 'model' ? effective.supportsImageInput
: target === 'opencode' ? effective.opencodeModelProfile?.supportsImageInput
@@ -1695,11 +1717,10 @@ export function registerIpcHandlers(
const toolStates = new Map(
recoveredTools.map((tool) => [tool.callId, tool])
)
const project = assistantDatabase.getProject(task.projectId)
const runtimeSelection =
conversation.runtimeSelection ??
project.runtimeSelection ??
({ provider: 'auto' } as const)
const runtimeSelection = await resolveStoredRuntimeSelection({
projectId: task.projectId,
conversationLayer: conversation.runtimeSelection
})
const controller = new AbortController()
const lease = leaseActiveRequest(
task.taskId,
@@ -2005,7 +2026,7 @@ export function registerIpcHandlers(
input: unknown
serializedContexts?: unknown
}
const input = conversationQueueUserInputSchema.parse(stored.input)
const input = conversationQueueUserInputSchema.parse(withoutLegacyAutoSelection(stored.input))
if (
stored.serializedContexts !== undefined &&
typeof stored.serializedContexts !== 'string'
@@ -2020,7 +2041,7 @@ export function registerIpcHandlers(
}
return input
}
return conversationQueueUserInputSchema.parse(parsed)
return conversationQueueUserInputSchema.parse(withoutLegacyAutoSelection(parsed))
}
const pumpingConversationQueues = new Set<string>()
@@ -2166,7 +2187,7 @@ export function registerIpcHandlers(
projectName: string
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
runtimeSelection?: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId: string
@@ -2396,7 +2417,9 @@ export function registerIpcHandlers(
conversationId: runtimeConversationId,
projectId: schedule.projectId,
workMode: schedule.workMode,
prompt: `${trustedInstructions}\n\n${schedule.prompt}`,
prompt: requestRuntime.consumesTrustedInstructions === true
? schedule.prompt
: `${trustedInstructions}\n\n${schedule.prompt}`,
knowledgeLibraryIds: [],
...(remoteContext?.contextIds?.length
? { contextIds: remoteContext.contextIds }
@@ -2956,9 +2979,10 @@ export function registerIpcHandlers(
}
}
const channelLabel = projectChannelLabels[channel]
const runtimeSelection = project.runtimeSelection ?? {
provider: 'auto' as const
}
// Without a selected-runtime manager the configured runtime is used directly.
const runtimeSelection = selectedRuntimes
? await resolveStoredRuntimeSelection({ project })
: undefined
const identitySuffix = message.senderId.slice(-4)
const senderDisplay = `发送者 ****${identitySuffix}`
const contextIds: string[] = []
@@ -3014,8 +3038,7 @@ export function registerIpcHandlers(
externalConversationId: message.conversationId,
conversationType: message.conversationType,
title: `${channelLabel} · ****${identitySuffix}`,
accountDisplay: senderDisplay,
runtimeSelection
accountDisplay: senderDisplay
})
const incomingMessageId = assistantDatabase.appendRemoteConversationMessage({
conversationId: remoteConversation.id,
@@ -3327,15 +3350,12 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.agentStatus, async (event, input: unknown) => {
assertTrustedSender(event, window)
const selection = agentRuntimeSelectionSchema.optional().parse(input)
const selection = optionalAgentRuntimeSelectionSchema.parse(withoutLegacyAutoSelection(input))
if (!selectedRuntimes) {
return runtime.getStatus()
}
const effectiveSelection =
selection ??
getDefaultRuntimeSelection(
await settingsStore.getResolvedSettings()
)
selection ?? (await resolveStoredRuntimeSelection({}))
return selectedRuntimes.getStatus(effectiveSelection)
})
@@ -3627,7 +3647,7 @@ export function registerIpcHandlers(
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新任务')
}
const parsedInput = agentRequestSchema.parse(input)
const parsedInput = agentRequestSchema.parse(withoutLegacyAutoSelection(input))
if (
activeRequests.has(parsedInput.requestId) ||
preparingRequestConversations.has(parsedInput.requestId)
@@ -3725,7 +3745,8 @@ export function registerIpcHandlers(
assistantDatabase.saveLocalConversations([{
header: {
id: conversation.id, projectId: conversation.projectId, title: conversation.title,
updatedAt: now, workMode: normalizedWorkMode, runtimeSelection: parsedRequest.runtimeSelection,
// The renderer owns the conversation layer; never pin the resolved selection here.
updatedAt: now, workMode: normalizedWorkMode, runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState,
@@ -3796,6 +3817,15 @@ export function registerIpcHandlers(
const configExecutionSpace = configProject
? spaceResolver.resolveProject(configProject)
: undefined
// Remote events key their context metrics by the selection actually used.
const remoteEventSelection =
enrichedRequest.runtimeSelection ??
(configExecutionSpace?.kind === 'ssh'
? await resolveStoredRuntimeSelection({
projectId: configProject?.id,
conversationLayer: assistantDatabase.getConversation(enrichedRequest.conversationId).runtimeSelection
})
: undefined)
const configWorkspacePath =
configAccess === 'none'
? undefined
@@ -4060,7 +4090,7 @@ export function registerIpcHandlers(
? assistantDatabase.appendRemoteConversationTaskEventOnce({
taskId: request.requestId,
conversationId: request.conversationId,
runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection,
runtimeSelection: remoteEventSelection,
assistantMessageId:
remoteConversationRecovery.currentAssistantMessageId,
bindingId: provenance.bindingId,
@@ -4088,7 +4118,7 @@ export function registerIpcHandlers(
? assistantDatabase.appendRemoteConversationTaskEventOnce({
taskId: request.requestId,
conversationId: request.conversationId,
runtimeSelection: request.runtimeSelection ?? configProject?.runtimeSelection,
runtimeSelection: remoteEventSelection,
assistantMessageId:
remoteConversationRecovery.currentAssistantMessageId,
bindingId: provenance.bindingId,
@@ -4317,7 +4347,7 @@ export function registerIpcHandlers(
markManagedSshAccepted()
}
return selectedRuntime.run(
modeInstruction
modeInstruction && selectedRuntime.consumesTrustedInstructions !== true
? {
...executionRequest,
prompt: `${modeInstruction}\n\n${executionRequest.prompt}`
@@ -4403,7 +4433,7 @@ export function registerIpcHandlers(
const contextMetricsEvent = runtimeUsageContextMetrics(
usageEvent as RuntimeModelUsageEvent,
runtimeSettings,
request.runtimeSelection
request.runtimeSelection ?? remoteEventSelection
)!
if (provenance === undefined) {
eventBuffer.push(contextMetricsEvent)
@@ -4739,7 +4769,8 @@ export function registerIpcHandlers(
const request = runtimeConversationCompactInputSchema.parse(input)
if (
request.runtimeSelection.provider !== 'opencode' &&
request.runtimeSelection.provider !== 'continue'
request.runtimeSelection.provider !== 'continue' &&
request.runtimeSelection.provider !== 'model'
) {
throw new Error('当前 Runtime 不支持手动压缩')
}
@@ -4753,12 +4784,10 @@ export function registerIpcHandlers(
const project = conversation.projectId
? assistantDatabase.getProject(conversation.projectId)
: undefined
const savedRuntimeSelection =
conversation.runtimeSelection ?? project?.runtimeSelection
const persistedRuntimeSelection =
savedRuntimeSelection && savedRuntimeSelection.provider !== 'auto'
? savedRuntimeSelection
: getDefaultRuntimeSelection(settings)
const persistedRuntimeSelection = await resolveStoredRuntimeSelection({
projectId: conversation.projectId,
conversationLayer: conversation.runtimeSelection
})
if (
conversation.projectId !== request.projectId ||
agentRuntimeSelectionKey(persistedRuntimeSelection) !==
@@ -4791,6 +4820,12 @@ export function registerIpcHandlers(
settings,
request.runtimeSelection
)
if (
request.runtimeSelection.provider === 'model' &&
!isAgentRuntimeModelProtocol(selected.settings.modelProtocol)
) {
throw new Error('当前模型连接不支持上下文摘要')
}
if (project?.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
@@ -4844,7 +4879,12 @@ export function registerIpcHandlers(
(candidate) =>
candidate.id === compressionSource.profileId
)
: selected.settings.continueModelProfile) ??
: request.runtimeSelection.provider === 'model'
? selected.settings.modelProfiles.find(
(candidate) =>
candidate.id === selected.settings.defaultModelProfileId
)
: selected.settings.continueModelProfile) ??
selected.settings.modelProfiles.find(
(candidate) =>
candidate.id ===
@@ -4854,8 +4894,8 @@ export function registerIpcHandlers(
selected.settings.modelProfiles.find((candidate) =>
isAgentRuntimeModelProtocol(candidate.protocol)
)
if (!profile) {
throw new Error('没有可用于 Continue 上下文摘要的文本模型连接')
if (!profile || !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error('没有可用于上下文摘要的文本模型连接')
}
if (
profile.authentication === 'api-key' &&
@@ -5910,7 +5950,7 @@ export function registerIpcHandlers(
const attachmentParsing = new Map<string, AbortController>()
registerHandler(ipcChannels.contextImageCapability, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { conversationId, runtimeSelection } = z.object({ conversationId: z.string().uuid(), runtimeSelection: agentRuntimeSelectionSchema.optional() }).strict().parse(input)
const { conversationId, runtimeSelection } = z.object({ conversationId: z.string().uuid(), runtimeSelection: optionalAgentRuntimeSelectionSchema }).strict().parse(withoutLegacyAutoSelection(input))
try {
const conversation = assistantDatabase.getConversation(conversationId)
await assertImageInputSupport({ requestId: randomUUID(), conversationId, projectId: conversation.projectId, runtimeSelection, prompt: '', workMode: 'ask' })
@@ -6397,13 +6437,12 @@ export function registerIpcHandlers(
assistantDatabase.setConversationPinned(
conversationSetPinnedSchema.parse(input)
)
contextManager.assets?.reconcile((conversationId, kind, ownerId) => assistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), false, contextManager.activeContextIds())
if (contextManager.assets) contextManager.cancelUnavailableImport()
publishConversationChange()
}
)
const supervisorService = createProductionSupervisorService(assistantDatabase,
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool)
async () => applicationSettingsStore?.get(), () => resolveRequestRuntime({ workMode: 'ask' }), supervisionModelPool,
persistModelUsage)
registerHandler(
ipcChannels.conversationsBranchLocal,
@@ -6468,7 +6507,7 @@ export function registerIpcHandlers(
const currentWorkMode = (): 'ask' | 'execute' => normalizeInteractiveWorkMode(assistantDatabase.getConversation(target.conversationId).workMode)
return imageGenerationService.regenerate({
conversationId: target.conversationId, messageId: previous.messageId,
requestId: randomUUID(), workMode: currentWorkMode()
requestId: previous.requestId, workMode: currentWorkMode()
}, target.operationId, currentWorkMode)
})
@@ -6489,7 +6528,7 @@ export function registerIpcHandlers(
if (executionPaused || shuttingDown) {
throw new Error('本地数据维护期间暂不接受新消息')
}
const parsed = conversationQueueUserInputSchema.parse(input)
const parsed = conversationQueueUserInputSchema.parse(withoutLegacyAutoSelection(input))
if (contextManager.assets) {
parsed.attachments = parsed.attachments.map((attachment) => contextManager.assets!.has(attachment.id) ? contextManager.assets!.get(attachment.id) : attachment)
}
@@ -7234,7 +7273,7 @@ export function registerIpcHandlers(
const queueInput = conversationQueueUserInputSchema.parse({
conversationId: conversation.id,
projectId: request.projectId ?? conversation.projectId ?? undefined,
runtimeSelection: request.runtimeSelection ?? conversation.runtimeSelection,
runtimeSelection: request.runtimeSelection,
workMode: conversation.workMode ?? 'ask',
includeMemoryContext: true,
prompt: request.prompt,
@@ -96,6 +96,25 @@ describe('createModelGraphExtractor', () => {
}
)
it.each([
['anthropic-messages', { content: [{ type: 'text', text: '{}' }], model: 'claude-x',
usage: { input_tokens: 11, output_tokens: 3, cache_read_input_tokens: 2, cache_creation_input_tokens: 1 } },
{ provider: 'anthropic', model: 'claude-x', input: 11, output: 3, cacheRead: 2, cacheWrite: 1 }],
['openai-chat-completions', { choices: [{ message: { content: '{}' } }],
usage: { prompt_tokens: 9, completion_tokens: 4, prompt_tokens_details: { cached_tokens: 5 } } },
{ provider: 'openai', model: 'intranet-model', input: 9, output: 4, cacheRead: 5, cacheWrite: 0 }]
] as const)('reports %s token usage', async (protocol, payload, expected) => {
const onUsage = vi.fn()
const extract = createModelGraphExtractor(
store({ modelProtocol: protocol }),
vi.fn<typeof fetch>(async () => jsonResponse(payload)),
undefined,
onUsage
)
await expect(extract('extract this')).resolves.toEqual({})
expect(onUsage).toHaveBeenCalledWith(expected)
})
it('uses an unauthenticated Anthropic endpoint with its path and query', async () => {
const fetcher = vi.fn(async () =>
jsonResponse({
+67 -1
View File
@@ -158,10 +158,64 @@ function completionDetail(payload: unknown): string | undefined {
: undefined
}
export type GraphExtractionUsage = {
provider: 'anthropic' | 'openai'
model: string
input: number
output: number
cacheRead: number
cacheWrite: number
}
function tokenCount(value: unknown): number {
return Number.isSafeInteger(value) && (value as number) >= 0
? value as number
: 0
}
/** Reads provider-reported token usage from a non-streaming response. */
export function readGraphExtractionUsage(
payload: unknown,
protocol: string,
fallbackModel: string
): GraphExtractionUsage | undefined {
const response = record(payload)
const usage = record(response?.usage)
if (!usage) {
return undefined
}
const model =
typeof response?.model === 'string' && response.model.length > 0
? response.model.slice(0, 500)
: fallbackModel.slice(0, 500)
if (protocol === 'anthropic-messages') {
return {
provider: 'anthropic',
model,
input: tokenCount(usage.input_tokens),
output: tokenCount(usage.output_tokens),
cacheRead: tokenCount(usage.cache_read_input_tokens),
cacheWrite: tokenCount(usage.cache_creation_input_tokens)
}
}
const details = record(
usage.prompt_tokens_details ?? usage.input_tokens_details
)
return {
provider: 'openai',
model,
input: tokenCount(usage.prompt_tokens ?? usage.input_tokens),
output: tokenCount(usage.completion_tokens ?? usage.output_tokens),
cacheRead: tokenCount(details?.cached_tokens),
cacheWrite: 0
}
}
export function createModelGraphExtractor(
settingsStore: RuntimeSettingsStore,
fetcher: typeof fetch = fetch,
requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds
requestTimeoutMilliseconds = defaultGraphRequestTimeoutMilliseconds,
onUsage?: (usage: GraphExtractionUsage) => void
): ExtractStructured {
return async (prompt, signal) => {
const settings = await settingsStore.getResolvedSettings()
@@ -289,6 +343,18 @@ export function createModelGraphExtractor(
{ cause: error }
)
}
const usage = readGraphExtractionUsage(
payload,
protocol,
settings.modelName
)
if (usage && onUsage) {
try {
onUsage(usage)
} catch {
// Usage accounting must never fail a successful extraction.
}
}
const text =
protocol === 'anthropic-messages'
? anthropicText(payload)
@@ -42,6 +42,16 @@ describe('OpenAIEmbeddingClient', () => {
})
})
it('reports provider token usage per batch without failing on observer errors', async () => {
const transport = vi.fn<typeof fetch>(async () =>
new Response(JSON.stringify({ data: [{ index: 0, embedding: [1, 2] }], usage: { prompt_tokens: 7, total_tokens: 7 } }))
)
const onUsage = vi.fn(() => { throw new Error('observer failed') })
const client = new OpenAIEmbeddingClient({ endpoint: 'http://127.0.0.1/v1/embeddings', model: 'embed', fetch: transport, onUsage })
await expect(client.embed(['alpha'])).resolves.toEqual([[1, 2]])
expect(onUsage).toHaveBeenCalledWith({ model: 'embed', inputTokens: 7 })
})
it('accepts unauthenticated endpoints and restores response index order', async () => {
const transport = vi.fn<typeof fetch>(async () =>
new Response(
+27 -4
View File
@@ -22,6 +22,8 @@ export interface OpenAIEmbeddingClientOptions {
batchSize?: number
timeoutMs?: number
fetch?: typeof fetch
/** Receives provider-reported input tokens for each successful batch. */
onUsage?: (usage: { model: string; inputTokens: number }) => void
}
export type EmbeddingInputRole = 'query' | 'document'
@@ -268,8 +270,10 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
private readonly batchSize: number
private readonly timeoutMs: number
private readonly transport: typeof fetch
private readonly onUsage?: OpenAIEmbeddingClientOptions['onUsage']
constructor(options: OpenAIEmbeddingClientOptions) {
this.onUsage = options.onUsage
this.endpoint = normalizedEndpoint(options.endpoint)
this.model = requiredString(options.model, 'model', MAX_MODEL_LENGTH)
this.apiKey = options.apiKey?.trim() || undefined
@@ -395,6 +399,26 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
return embeddings
}
private reportUsage(payload: unknown): void {
if (!this.onUsage || typeof payload !== 'object' || payload === null) {
return
}
const usage = (payload as { usage?: unknown }).usage
if (typeof usage !== 'object' || usage === null) {
return
}
const values = usage as Record<string, unknown>
const tokens = values.prompt_tokens ?? values.total_tokens
if (!Number.isSafeInteger(tokens) || (tokens as number) <= 0) {
return
}
try {
this.onUsage({ model: this.model, inputTokens: tokens as number })
} catch {
// Usage accounting must never fail an embedding request.
}
}
private async embedBatch(
input: readonly string[],
signal?: AbortSignal
@@ -439,10 +463,9 @@ export class OpenAIEmbeddingClient implements EmbeddingProvider {
`Embedding request failed with HTTP ${response.status}`
)
}
const vectors = validateEmbeddings(
await readBoundedJson(response),
input.length
)
const payload = await readBoundedJson(response)
const vectors = validateEmbeddings(payload, input.length)
this.reportUsage(payload)
if (
this.dimensions !== undefined &&
vectors.some((vector) => vector.length !== this.dimensions)
@@ -8,6 +8,12 @@ import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magi
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database'
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
const readFileSync = vi.fn(actual.readFileSync)
return { ...actual, readFileSync, default: { ...actual, readFileSync } }
})
const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync }
const directories: string[] = []
@@ -16,6 +22,7 @@ afterEach(() => {
vi.restoreAllMocks()
for (const connection of connections.splice(0)) connection.close()
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
vi.mocked(readFileSync).mockClear()
})
function setup(options: ConstructorParameters<typeof AssistantDatabase>[1] = {}) {
@@ -53,6 +60,36 @@ function canvas(size = 40): MagicNoteCanvasContent {
}
describe('Magic note SQLite and filesystem storage', () => {
it('measures current entry startup body reads on real SQLite and filesystem fixtures', () => {
const { database, directory, sql } = setup()
const paths: string[] = []
for (let index = 0; index < 120; index++) {
const content = index % 2 ? canvas() : structuredClone(rich)
if (content.version === 1) content.ops.unshift({ insert: 'x'.repeat(32 * 1024) })
else content.pages[0]!.objects.push({ type: 'IText', text: 'x'.repeat(32 * 1024) })
const note = database.createMagicNote({ title: `Synthetic ${index}`, content })
paths.push(join(directory, 'notes', note.id, 'entries', `${note.entries[0]!.id}.json`))
}
database.close()
const bodyPaths = new Set(paths)
const reads = vi.mocked(readFileSync)
const durations: number[] = []
const counts: number[] = []
for (let run = 0; run < 5; run++) {
reads.mockClear()
const start = performance.now()
database.initialize(directory)
durations.push(Number((performance.now() - start).toFixed(2)))
counts.push(reads.mock.calls.filter(([path]) => bodyPaths.has(String(path))).length)
for (const path of paths) expect(reads.mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
expect(reads.mock.calls.some(([path]) => String(path).includes(join(directory, 'notes')) && String(path).includes('assets'))).toBe(false)
database.close()
}
console.info('Synthetic note startup', { entries: paths.length, bodyBytes: paths.reduce((sum, path) => sum + statSync(path).size, 0), counts, durationsMs: durations })
expect(counts).toEqual(Array(5).fill(paths.length))
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
})
it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => {
const { database, sql, path, directory } = setup()
const created = database.createMagicNote({ title: 'Existing note', content: rich })
@@ -77,6 +114,43 @@ describe('Magic note SQLite and filesystem storage', () => {
expect(database.getMagicNote(created.id)).toEqual(before)
})
it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
const entries = database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' }).entries
const root = join(directory, 'notes', note.id)
const bodyPath = join(root, 'entries', `${entries[1]!.id}.json`)
const original = readFileSync(bodyPath, 'utf8')
const manifest = readFileSync(join(root, 'note.json'), 'utf8')
writeFileSync(join(root, 'assets', 'unused.bin'), 'keep until inspection succeeds')
const assets = readdirSync(join(root, 'assets')).map((name) => [name, readFileSync(join(root, 'assets', name))] as const)
database.close()
if (failure === 'missing') rmSync(bodyPath)
else if (failure === 'invalid-json') writeFileSync(bodyPath, '{')
else {
const body = JSON.parse(original)
body.content.pages = null
writeFileSync(bodyPath, JSON.stringify(body))
}
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
if (failure === 'invalid-media') {
database.initialize(directory)
expect(warning).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id }))
} else expect(() => database.initialize(directory)).toThrow()
for (const [name, bytes] of assets) expect(readFileSync(join(root, 'assets', name))).toEqual(bytes)
expect(readFileSync(join(root, 'note.json'), 'utf8')).toBe(manifest)
database.close()
writeFileSync(bodyPath, original)
vi.mocked(readFileSync).mockClear()
database.initialize(directory)
for (const entry of entries) {
const path = join(root, 'entries', `${entry.id}.json`)
expect(vi.mocked(readFileSync).mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
}
expect(database.getMagicNote(note.id).entries).toEqual(entries)
expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false)
})
it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => {
const { database, sql, path, directory } = setup()
const text = 'data:text/plain;base64,aGVsbG8'
@@ -339,14 +413,20 @@ describe('Magic note SQLite and filesystem storage', () => {
const { database, sql, directory } = setup()
const note = database.createMagicNote({ title: 'Repair', content: rich })
const entry = note.entries[0]!
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision,
comments: [{ id: randomUUID(), kind: 'summary', content: 'Stale analysis' }] })
const analyzed = database.getMagicNoteEntry(entry.id)
sql.exec(`CREATE TRIGGER fail_note_update BEFORE UPDATE OF content_json ON magic_note_entries
BEGIN SELECT RAISE(ABORT, 'index unavailable'); END`)
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
expect(() => database.searchMagicNotes('PDF text', 10)).toThrow('index unavailable')
sql.exec('DROP TRIGGER fail_note_update')
if (reopen) { database.close(); database.initialize(directory) }
expect(database.searchMagicNotes('PDF text', 10)).toHaveLength(1)
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: 1 })
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: analyzed.revision + 1, comments: [], analyzedAt: undefined })
const assetNames = readdirSync(join(directory, 'notes', note.id, 'assets'))
expect(assetNames).toHaveLength(2)
expect(assetNames.some((name) => name.endsWith('.pdf'))).toBe(true)
expect(database.listMagicTodos()).toEqual([expect.objectContaining({ title: 'Canvas task' })])
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow('已被更新')
})
+25 -11
View File
@@ -75,8 +75,19 @@ export class MagicNoteStorage {
return join(this.rootPath, noteId, 'entries', `${entryId}.json`)
}
revision(noteId: string, entryId: string): number {
return (JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry).revision
inspect(noteId: string, entryId: string): { revision: number; referencedAssets?: Set<string> } {
const entry = JSON.parse(readFileSync(this.entryPath(noteId, entryId), 'utf8')) as MagicNoteStoredEntry
const referencedAssets = new Set<string>()
try {
mapMediaFields(entry.content, (value) => {
if (value && typeof value === 'object' && '$asset' in value) referencedAssets.add(String(value.$asset))
return value
})
} catch {
// Let reconciliation retry failed media inspection through its deferred-cleanup path.
return { revision: entry.revision }
}
return { revision: entry.revision, referencedAssets }
}
write(entry: MagicNoteStoredEntry): void {
@@ -108,18 +119,21 @@ export class MagicNoteStorage {
return entry
}
reconcile(noteId: string, entryIds: string[]): void {
reconcile(noteId: string, entryIds: string[], inspectedAssets?: ReadonlySet<string>): void {
const notePath = join(this.rootPath, noteId)
const entriesPath = join(notePath, 'entries')
const retained = new Set(entryIds.map((id) => `${id}.json`))
const referenced = new Set<string>()
// Read every retained body before deleting anything, including shared assets.
for (const name of retained) {
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
mapMediaFields(entry.content, (value) => {
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
return value
})
// Repair can supply references only after inspecting every retained body.
// Otherwise read them all before deleting anything, including shared assets.
if (!inspectedAssets) {
for (const name of retained) {
const entry = JSON.parse(readFileSync(join(entriesPath, name), 'utf8')) as MagicNoteStoredEntry
mapMediaFields(entry.content, (value) => {
if (value && typeof value === 'object' && '$asset' in value) referenced.add(String(value.$asset))
return value
})
}
}
this.atomicWrite(join(notePath, 'note.json'), JSON.stringify({ id: noteId, entries: entryIds }))
if (existsSync(entriesPath)) {
@@ -133,7 +147,7 @@ export class MagicNoteStorage {
const assetsPath = join(notePath, 'assets')
if (existsSync(assetsPath)) {
for (const name of readdirSync(assetsPath)) {
if (!referenced.has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
if (!(inspectedAssets ?? referenced).has(`assets/${name}`)) fs.rmSync(join(assetsPath, name), { force: true })
}
}
}
@@ -1,7 +1,10 @@
import { join } from 'node:path'
import { SqliteRuntimeSessionBindingStore } from '../agent/runtime-session-binding-store'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type {
AgentRuntimeSelection,
RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import type { SshHostStore } from '../ssh/ssh-host-store'
import {
resolveBundledRemoteRuntimeResourcePaths,
@@ -44,7 +47,7 @@ export type ManagedRemoteExecutionServicesOptions = {
selection: AgentRuntimeSelection
): Promise<ResolvedModelProfile | undefined>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
layer: RuntimeSelectionLayer | undefined
): Promise<AgentRuntimeSelection>
}
@@ -1,7 +1,10 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssistantProject } from '../../shared/assistant-contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type {
AgentRuntimeSelection,
RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import type { SshConnectionTarget } from '../ssh/ssh-host-store'
import { verifyAgentInstallationId } from '../ssh/ssh-agent-command'
import type { AgentInstallationIdentity } from './agent-installation-manager'
@@ -110,7 +113,7 @@ type HarnessOptions = {
create?: () => AssistantProject
update?: () => AssistantProject
resolveRuntimeSelection?: (
selection: AgentRuntimeSelection
layer: RuntimeSelectionLayer | undefined
) => Promise<AgentRuntimeSelection>
}
@@ -262,7 +265,7 @@ function harness(options: HarnessOptions = {}) {
},
resolveRuntimeSelection:
options.resolveRuntimeSelection ??
(async (selection) => selection),
(async (layer) => ({ provider: layer?.provider === 'continue' ? 'continue' : 'opencode' }) as AgentRuntimeSelection),
runtimeValidator: {
validate: vi.fn(async () => {
calls.push('runtime')
@@ -11,7 +11,10 @@ import {
remoteWorkspaceValidateResultSchema,
type RemoteWorkspaceHandle
} from '../../shared/remote-agent-contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type {
AgentRuntimeSelection,
RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import type {
AssistantDatabase,
SshProjectWrite
@@ -99,11 +102,11 @@ type ActiveSave = {
type RemoteProjectPreparationDraft = Readonly<{
hostId: string
remoteRootPath: string
runtimeSelection: AgentRuntimeSelection
runtimeSelection?: RuntimeSelectionLayer
}>
type PreparedRemoteProject = Readonly<{
runtimeSelection: AgentRuntimeSelection
runtimeSelection?: RuntimeSelectionLayer
target: SshConnectionTarget
installation: AgentInstallationIdentity
connection: RemoteAgentConnection
@@ -125,7 +128,7 @@ export type RemoteProjectSaveServiceOptions = {
installationManager: Pick<AgentInstallationManager, 'activateInstalled'>
connectionManager: Pick<RemoteAgentConnectionManager, 'acquire'>
resolveRuntimeSelection(
selection: AgentRuntimeSelection
layer: RuntimeSelectionLayer | undefined
): Promise<AgentRuntimeSelection>
runtimeValidator?: RemoteProjectRuntimeValidator
notify?: (
+9 -1
View File
@@ -430,6 +430,8 @@ export type ResolvedRuntimeSettings = {
opencodeModelProfile?: ResolvedModelProfile
continueModelProfile?: ResolvedModelProfile
deepseekHarnessModelProfile?: ResolvedModelProfile
/** What DeepSeek Harness uses when its own configuration is selected. */
deepseekHarnessPlatformModelProfile?: ResolvedModelProfile
opencodeBaseUrl: string
opencodeEmbedded: boolean
opencodeBinaryPath: string
@@ -1989,6 +1991,11 @@ export class RuntimeSettingsStore {
: undefined
const deepseekHarnessModelProfile =
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
const deepseekHarnessPlatformModelProfile =
this.resolveDeepSeekHarnessModelProfile(
{ ...settings, deepseekHarnessModelSource: { kind: 'platform' } },
modelProfiles
)
const embeddingConnections =
this.resolveEmbeddingConnections(settings)
const activeEmbeddingConnection =
@@ -2014,6 +2021,7 @@ export class RuntimeSettingsStore {
defaultImageModelProfileId: settings.defaultImageModelProfileId,
continueModelProfile,
deepseekHarnessModelProfile,
deepseekHarnessPlatformModelProfile,
...agent,
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
@@ -2530,7 +2538,7 @@ export class RuntimeSettingsStore {
const next: StoredSettings = {
...current,
version: CURRENT_SETTINGS_VERSION,
provider: input.provider,
provider: input.provider === 'auto' ? 'model' : input.provider,
modelProfiles,
defaultModelProfileId,
opencodeModelSource,
@@ -102,6 +102,23 @@ describe('ShortcutSettingsService', () => {
)
})
it.each(['disabled', 'conflict', 'failed'] as const)('reports an unavailable shortcut at startup when %s', async (status) => {
const fixture = createFixture({
enabled: status !== 'disabled',
accelerator: 'CommandOrControl+Shift+Space'
})
if (status === 'conflict') fixture.registry.register.mockReturnValueOnce(false)
if (status === 'failed') fixture.registry.register.mockImplementationOnce(() => { throw new Error('Registration failed') })
await expect(fixture.service.initialize()).resolves.toMatchObject({
registered: false,
displayAccelerator: 'Ctrl+Shift+Space',
status
})
expect(fixture.service.getSnapshot().registeredAccelerator).toBeUndefined()
if (status === 'disabled') expect(fixture.registry.register).not.toHaveBeenCalled()
})
it.each([
[
'win32',
+7 -1
View File
@@ -1283,7 +1283,13 @@ const desktopApi: DesktopApi = {
getTokenSummary: () =>
ipcRenderer.invoke(
ipcChannels.tokenUsageSummary
) as Promise<TokenUsageSummary>
) as Promise<TokenUsageSummary>,
onChanged: (listener) => {
const handler = (): void => listener()
ipcRenderer.on(ipcChannels.tokenUsageChanged, handler)
return () =>
ipcRenderer.removeListener(ipcChannels.tokenUsageChanged, handler)
}
},
artifacts: {
list: (projectId?: string) =>
+92
View File
@@ -15,6 +15,18 @@ import { ActivityPanel } from './ActivityPanel'
import { upsertActivityRecord, type ActivityRecord } from './activity-store'
import i18n from './i18n'
const activityRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./WorkspacePrimitives', async (importOriginal) => {
const original = await importOriginal<typeof import('./WorkspacePrimitives')>()
return {
...original,
PageHeader: (props: Parameters<typeof original.PageHeader>[0]) => {
if (props.headingId === 'activity-panel-title') activityRenderProbe()
return <original.PageHeader {...props} />
}
}
})
function makeRecord(
index: number,
status: ActivityRecord['status'] = 'completed'
@@ -77,6 +89,37 @@ function makeTokenUsage(): TokenUsageSummary {
}
describe('ActivityPanel', () => {
it('skips unchanged hidden renders while retaining selection and accepting real updates', () => {
const record = makeRecord(1, 'running')
const props = { records: [record], tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() }
const { rerender } = render(<div hidden={false}><ActivityPanel {...props} /></div>)
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
const node = screen.getByRole('button', { name: /活动 1,工具/u })
fireEvent.click(node)
const detail = screen.getByLabelText('选中的活动节点详情')
activityRenderProbe.mockClear()
for (let index = 0; index < 3; index++) {
rerender(<div hidden data-update={index}><ActivityPanel {...props} /></div>)
}
expect(activityRenderProbe).toHaveBeenCalledTimes(0)
const updated = { ...props, records: [{ ...record, detail: 'Real update', status: 'failed' as const }], onOpenConversation: vi.fn(), onClear: vi.fn() }
rerender(<div hidden><ActivityPanel {...updated} /></div>)
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
rerender(<div hidden={false}><ActivityPanel {...updated} /></div>)
expect(activityRenderProbe).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail)
expect(node).toHaveAttribute('aria-pressed', 'true')
expect(node).toHaveClass('activity-track__node--failed')
expect(within(detail).getByText('Real update')).toBeInTheDocument()
fireEvent.click(within(detail).getByRole('button', { name: '打开所属对话' }))
expect(updated.onOpenConversation).toHaveBeenCalledWith(record.conversationId)
expect(props.onOpenConversation).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '清空记录' }))
fireEvent.click(screen.getByRole('button', { name: '确认清空 1 条活动记录' }))
expect(updated.onClear).toHaveBeenCalledOnce()
expect(props.onClear).not.toHaveBeenCalled()
})
it('opens timeline guidance separately from tab navigation', () => {
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[makeRecord(1)]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '活动时间线' }))
@@ -889,4 +932,53 @@ describe('ActivityPanel', () => {
).toBeInTheDocument()
expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument()
})
it('moves system task usage into its own tab with separate totals', () => {
const usage = makeTokenUsage()
const systemRecord = (requestId: string, systemSource: 'heartbeat' | 'supervision', input: number) => ({
requestId, systemSource, conversationId: `${systemSource}:x`, runtime: 'model', provider: 'openai', model: 'gpt-sys',
callCount: 1, input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1
})
usage.records.push(systemRecord('sys-1', 'heartbeat', 7), systemRecord('sys-2', 'supervision', 9))
usage.conversationTotals = usage.totals
usage.systemTotals = { callCount: 2, input: 16, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 18 }
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={usage} />)
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
fireEvent.click(screen.getByRole('button', { name: '按会话' }))
expect(screen.queryByText('heartbeat:x')).not.toBeInTheDocument()
expect(within(screen.getByLabelText('Token 用量统计')).getByText('150')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
const stats = screen.getByLabelText('系统任务用量统计')
expect(within(stats).getByText('18')).toBeInTheDocument()
expect(screen.getByRole('row', { name: '自动监督 7 1 0 0 0% 8' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '监督者' }))
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 9 1 0 0 0% 10' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
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 () => {
let resolveRefresh!: () => void
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
records={[]} tokenUsage={makeTokenUsage()} />)
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
const busy = screen.getByRole('button', { name: '正在刷新' })
expect(busy).toBeDisabled()
fireEvent.click(busy)
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
resolveRefresh()
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
})
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: '用量统计' }))
expect(screen.queryByRole('button', { name: '刷新' })).not.toBeInTheDocument()
})
})
+272 -165
View File
@@ -1,5 +1,5 @@
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Activity, ChevronRight, RefreshCw, Trash2 } from 'lucide-react'
import { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
@@ -11,7 +11,9 @@ import { type ActivityRecord } from './activity-store'
import {
getTokenUsageTotals,
groupTokenUsage,
type TokenUsageGroup
splitTokenUsage,
type TokenUsageGroup,
type TokenUsageGroupRow
} from './token-usage'
import {
DestructiveConfirmActions,
@@ -25,7 +27,9 @@ import {
import { getProjectDisplayText } from './project-display'
type ActivityFilter = 'all' | 'active' | 'failed'
type ActivityView = 'tasks' | 'timeline' | 'usage'
type ActivityView = 'tasks' | 'timeline' | 'usage' | 'system'
type SystemTokenUsageGroup = Extract<TokenUsageGroup, 'source' | 'model'>
type TokenUsageView = Extract<ActivityView, 'usage' | 'system'>
type ActivityActorKind =
| 'user'
| 'assistant'
@@ -41,6 +45,8 @@ 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>
}
type ConversationActivityGroup = {
@@ -239,12 +245,13 @@ function groupActivityRecordsByProject(
})
}
export function ActivityPanel({
export const ActivityPanel = memo(function ActivityPanel({
projects = [],
records,
tokenUsage,
onClear,
onOpenConversation
onOpenConversation,
onRefreshTokenUsage
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
@@ -252,7 +259,10 @@ export function ActivityPanel({
useState<ActivityView>('tasks')
const [filter, setFilter] = useState<ActivityFilter>('all')
const [tokenGroup, setTokenGroup] =
useState<TokenUsageGroup>('project')
useState<Exclude<TokenUsageGroup, 'source'>>('project')
const [systemTokenGroup, setSystemTokenGroup] =
useState<SystemTokenUsageGroup>('source')
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
() => new Set()
)
@@ -311,10 +321,27 @@ export function ActivityPanel({
}> = [
{ id: 'tasks', label: t('tabs.tasks') },
{ id: 'timeline', label: t('tabs.timeline') },
{ id: 'usage', label: t('tabs.usage') }
{ id: 'usage', label: t('tabs.usage') },
{ id: 'system', label: t('tabs.system') }
]
const systemTokenGroups: ReadonlyArray<{
value: SystemTokenUsageGroup
label: string
columnLabel: string
}> = [
{
value: 'source',
label: t('systemUsage.groups.source'),
columnLabel: t('systemUsage.columns.source')
},
{
value: 'model',
label: t('tokenUsage.groups.model'),
columnLabel: t('tokenUsage.columns.model')
}
]
const tokenGroups: ReadonlyArray<{
value: TokenUsageGroup
value: Exclude<TokenUsageGroup, 'source'>
label: string
columnLabel: string
}> = [
@@ -452,17 +479,26 @@ export function ActivityPanel({
})
}
]
const splitUsage = useMemo(
() => splitTokenUsage(displayTokenUsage),
[displayTokenUsage]
)
const tokenTotals = useMemo(
() => getTokenUsageTotals(tokenUsage),
[tokenUsage]
() => getTokenUsageTotals(splitUsage.conversation),
[splitUsage]
)
const tokenRows = useMemo(
() => groupTokenUsage(displayTokenUsage, tokenGroup),
[displayTokenUsage, tokenGroup]
() => groupTokenUsage(splitUsage.conversation, tokenGroup),
[splitUsage, tokenGroup]
)
const systemTokenTotals = useMemo(
() => getTokenUsageTotals(splitUsage.system),
[splitUsage]
)
const systemTokenRows = useMemo(
() => groupTokenUsage(splitUsage.system, systemTokenGroup),
[splitUsage, systemTokenGroup]
)
const tokenGroupLabel =
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
t('tokenUsage.columns.project')
const emptyDescription =
filter === 'active'
? t('empty.active')
@@ -530,8 +566,25 @@ export function ActivityPanel({
{ kind: 'tool', label: t('timeline.actors.tool') },
{ kind: 'approval', label: t('timeline.actors.approval') }
]
const systemSourceLabel = (key: string): string => {
switch (key) {
case 'source:heartbeat':
return t('systemUsage.sources.heartbeat')
case 'source:supervision':
return t('systemUsage.sources.supervision')
case 'source:magic-notes':
return t('systemUsage.sources.magicNotes')
case 'source:knowledge':
return t('systemUsage.sources.knowledge')
case 'source:delegation':
return t('systemUsage.sources.delegation')
default:
return t('systemUsage.sources.unknown')
}
}
const localizeTokenRow = (
row: (typeof tokenRows)[number]
row: TokenUsageGroupRow,
group: TokenUsageGroup
): { label: string } => {
const modelLabel =
row.model || t('tokenUsage.fallbacks.unknownModel')
@@ -546,17 +599,30 @@ export function ActivityPanel({
? t('tokenUsage.runtimes.deepseekHarness')
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
const label =
tokenGroup === 'project' &&
row.key === 'project:unassigned'
? t('tokenUsage.fallbacks.unassignedProject')
: tokenGroup === 'conversation' &&
row.key === 'conversation:deleted'
? t('tokenUsage.fallbacks.deletedConversation')
: !row.children
? `${runtimeLabel} · ${modelLabel}`
: row.label
group === 'source' && row.children
? systemSourceLabel(row.key)
: group === 'project' &&
row.key === 'project:unassigned'
? t('tokenUsage.fallbacks.unassignedProject')
: group === 'conversation' &&
row.key === 'conversation:deleted'
? t('tokenUsage.fallbacks.deletedConversation')
: !row.children
? `${runtimeLabel} · ${modelLabel}`
: row.label
return { label }
}
const refreshTokenUsage = async (): Promise<void> => {
if (!onRefreshTokenUsage || refreshingTokenUsage) {
return
}
setRefreshingTokenUsage(true)
try {
await onRefreshTokenUsage()
} finally {
setRefreshingTokenUsage(false)
}
}
const renderRecordCard = (
record: ActivityRecord,
showContext: boolean
@@ -984,135 +1050,195 @@ export function ActivityPanel({
</div>
)}
{activeView === 'usage' && (
<div
aria-labelledby="activity-view-tab-usage"
className="activity-panel__tab-panel"
id="activity-view-panel-usage"
role="tabpanel"
tabIndex={0}
>
<section
aria-labelledby="token-usage-title"
className="token-usage"
>
<header className="token-usage__header">
<h2 id="token-usage-title">{t('tokenUsage.title')}</h2>
<SegmentedControl
ariaLabel={t('tokenUsage.groupAriaLabel')}
onChange={setTokenGroup}
options={tokenGroups}
value={tokenGroup}
/>
</header>
{activeView === 'usage' &&
renderTokenUsagePanel({
view: 'usage',
title: t('tokenUsage.title'),
description: undefined,
group: tokenGroup,
groups: tokenGroups,
onGroupChange: setTokenGroup,
totals: tokenTotals,
rows: tokenRows,
empty: t('tokenUsage.empty')
})}
<dl
aria-label={t('tokenUsage.statsAriaLabel')}
className="token-usage__stats"
>
<div>
<dt>{t('tokenUsage.columns.input')}</dt>
<dd>
{tokenCountFormatter.format(tokenTotals.inputTokens)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.output')}</dt>
<dd>
{tokenCountFormatter.format(tokenTotals.outputTokens)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
<dd>
{tokenCountFormatter.format(
tokenTotals.cacheWriteTokens
)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
<dd>
{tokenCountFormatter.format(
tokenTotals.cacheReadTokens
)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
<dd>
{formatCacheHitRate(tokenTotals.cacheHitRate)}
</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.total')}</dt>
<dd>
{tokenCountFormatter.format(tokenTotals.totalTokens)}
</dd>
</div>
</dl>
{activeView === 'system' &&
renderTokenUsagePanel({
view: 'system',
title: t('systemUsage.title'),
description: t('systemUsage.description'),
group: systemTokenGroup,
groups: systemTokenGroups,
onGroupChange: setSystemTokenGroup,
totals: systemTokenTotals,
rows: systemTokenRows,
empty: t('systemUsage.empty')
})}
</section>
)
<div className="token-usage__table-scroll">
<table
aria-label={t('tokenUsage.detailAriaLabel', {
group: tokenGroupLabel
})}
function renderTokenUsagePanel<Group extends TokenUsageGroup>({
view,
title,
description,
group,
groups,
onGroupChange,
totals,
rows,
empty
}: {
view: TokenUsageView
title: string
description: string | undefined
group: Group
groups: ReadonlyArray<{ value: Group; label: string; columnLabel: string }>
onGroupChange: (group: Group) => void
totals: ReturnType<typeof getTokenUsageTotals>
rows: TokenUsageGroupRow[]
empty: string
}): React.JSX.Element {
const titleId = view === 'usage' ? 'token-usage-title' : 'system-usage-title'
const groupLabel =
groups.find((item) => item.value === group)?.columnLabel ??
groups[0]!.columnLabel
const expansionPrefix = `${view}:`
return (
<div
aria-labelledby={`activity-view-tab-${view}`}
className="activity-panel__tab-panel"
id={`activity-view-panel-${view}`}
role="tabpanel"
tabIndex={0}
>
<section aria-labelledby={titleId} className="token-usage">
<header className="token-usage__header">
<h2 id={titleId}>{title}</h2>
<SegmentedControl
ariaLabel={
view === 'usage'
? t('tokenUsage.groupAriaLabel')
: t('systemUsage.groupAriaLabel')
}
onChange={onGroupChange}
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"
>
<thead>
<RefreshCw aria-hidden="true" size={13} />
{refreshingTokenUsage
? t('tokenUsage.refreshing')
: t('tokenUsage.refresh')}
</button>
)}
</header>
{description && (
<p className="token-usage__description">{description}</p>
)}
<dl
aria-label={
view === 'usage'
? t('tokenUsage.statsAriaLabel')
: t('systemUsage.statsAriaLabel')
}
className="token-usage__stats"
>
<div>
<dt>{t('tokenUsage.columns.input')}</dt>
<dd>{tokenCountFormatter.format(totals.inputTokens)}</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.output')}</dt>
<dd>{tokenCountFormatter.format(totals.outputTokens)}</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
<dd>{tokenCountFormatter.format(totals.cacheWriteTokens)}</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
<dd>{tokenCountFormatter.format(totals.cacheReadTokens)}</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
<dd>{formatCacheHitRate(totals.cacheHitRate)}</dd>
</div>
<div>
<dt>{t('tokenUsage.columns.total')}</dt>
<dd>{tokenCountFormatter.format(totals.totalTokens)}</dd>
</div>
</dl>
<div className="token-usage__table-scroll">
<table
aria-label={t('tokenUsage.detailAriaLabel', {
group: groupLabel
})}
>
<thead>
<tr>
<th scope="col">{groupLabel}</th>
<th scope="col">{t('tokenUsage.columns.input')}</th>
<th scope="col">{t('tokenUsage.columns.output')}</th>
<th scope="col">{t('tokenUsage.columns.cacheWrite')}</th>
<th scope="col">{t('tokenUsage.columns.cacheRead')}</th>
<th scope="col">{t('tokenUsage.columns.cacheHitRate')}</th>
<th scope="col">{t('tokenUsage.columns.total')}</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<th scope="col">{tokenGroupLabel}</th>
<th scope="col">{t('tokenUsage.columns.input')}</th>
<th scope="col">{t('tokenUsage.columns.output')}</th>
<th scope="col">
{t('tokenUsage.columns.cacheWrite')}
</th>
<th scope="col">
{t('tokenUsage.columns.cacheRead')}
</th>
<th scope="col">
{t('tokenUsage.columns.cacheHitRate')}
</th>
<th scope="col">
{t('tokenUsage.columns.total')}
</th>
<td className="token-usage__empty" colSpan={7}>
{empty}
</td>
</tr>
</thead>
<tbody>
{tokenRows.length === 0 ? (
<tr>
<td className="token-usage__empty" colSpan={7}>
{t('tokenUsage.empty')}
</td>
</tr>
) : (
tokenRows.flatMap((parent) => [
) : (
rows
.flatMap((parent) => [
{ row: parent, isChild: false },
...(expandedTokenRows.has(parent.key)
...(expandedTokenRows.has(expansionPrefix + parent.key)
? (parent.children ?? []).map((row) => ({
row: { ...row, key: `${parent.key}:${row.key}` },
isChild: true
}))
: [])
]).map(({ row, isChild }) => {
const localizedRow = localizeTokenRow(row)
])
.map(({ row, isChild }) => {
const localizedRow = localizeTokenRow(row, group)
const expansionKey = expansionPrefix + row.key
return (
<tr
key={row.key}
className={isChild ? 'token-usage__model-row' : undefined}
className={
isChild ? 'token-usage__model-row' : undefined
}
>
<th scope="row">
{row.children ? (
<button
type="button"
className="token-usage__expand"
aria-expanded={expandedTokenRows.has(row.key)}
aria-expanded={expandedTokenRows.has(
expansionKey
)}
onClick={() =>
setExpandedTokenRows((current) => {
const next = new Set(current)
if (next.has(row.key)) {
next.delete(row.key)
if (next.has(expansionKey)) {
next.delete(expansionKey)
} else {
next.add(row.key)
next.add(expansionKey)
}
return next
})
@@ -1125,44 +1251,25 @@ export function ActivityPanel({
<span>{localizedRow.label}</span>
)}
</th>
<td>{tokenCountFormatter.format(row.inputTokens)}</td>
<td>{tokenCountFormatter.format(row.outputTokens)}</td>
<td>
{tokenCountFormatter.format(
row.inputTokens
)}
{tokenCountFormatter.format(row.cacheWriteTokens)}
</td>
<td>
{tokenCountFormatter.format(
row.outputTokens
)}
</td>
<td>
{tokenCountFormatter.format(
row.cacheWriteTokens
)}
</td>
<td>
{tokenCountFormatter.format(
row.cacheReadTokens
)}
</td>
<td>
{formatCacheHitRate(row.cacheHitRate)}
</td>
<td>
{tokenCountFormatter.format(
row.totalTokens
)}
{tokenCountFormatter.format(row.cacheReadTokens)}
</td>
<td>{formatCacheHitRate(row.cacheHitRate)}</td>
<td>{tokenCountFormatter.format(row.totalTokens)}</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
</div>
)}
</section>
)
}
)}
</tbody>
</table>
</div>
</section>
</div>
)
}
})
File diff suppressed because it is too large Load Diff
+270 -571
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -8,6 +8,46 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n')
describe('primary sidebar layout', () => {
it('keeps the original new conversation typography and left alignment, centering only its compact icon', () => {
const button = stylesheet.match(/\.new-chat\s*\{([^}]*)\}/u)?.[1]
const label = stylesheet.match(/\.new-chat span\s*\{([^}]*)\}/u)?.[1]
const compact = stylesheet.match(/\.sidebar-conversation-controls--searching \.new-chat\s*\{([^}]*)\}/u)?.[1]
expect(button).toContain('justify-content: flex-start;')
expect(button).toContain('padding: 0 var(--space-3);')
expect(label).toContain('font-size: 13px;')
expect(label).toContain('font-weight: 600;')
expect(label).not.toContain('white-space: nowrap;')
expect(compact).toContain('justify-content: center;')
expect(compact).toContain('padding: 0;')
})
it('keeps conversation controls fixed above an independently scrolling list', () => {
const section = stylesheet.match(/\.sidebar-conversations\s*\{([^}]*)\}/u)?.[1]
const controls = stylesheet.match(/\.sidebar-conversation-controls\s*\{([^}]*)\}/u)?.[1]
const list = stylesheet.match(/\.conversation-list\s*\{([^}]*)\}/u)?.[1]
expect(section).toContain('min-height: 0;')
expect(section).toContain('flex-direction: column;')
expect(section).toContain('background: var(--surface-raised);')
expect(controls).toContain('flex: 0 0 44px;')
expect(controls).toContain('height: 44px;')
expect(stylesheet).not.toContain('.sidebar-conversations__header')
expect(list).toContain('min-width: 0;')
expect(list).toContain('min-height: 0;')
expect(list).toContain('overflow: auto;')
const shortWindow = stylesheet.split('@media (max-height: 600px) {')[1]?.split('@media')[0]
expect(shortWindow).toContain('overflow-y: auto;')
expect(shortWindow).toContain('flex-shrink: 0;')
expect(shortWindow).toContain('min-height: 200px;')
})
it('expands search within the row and disables its width animation for reduced motion', () => {
const slot = stylesheet.match(/\.sidebar-search-slot\s*\{([^}]*)\}/u)?.[1]
expect(slot).toContain('margin-left: auto;')
expect(slot).toContain('transition: width var(--motion-normal) ease-out;')
expect(stylesheet).toMatch(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.sidebar-search-slot\s*\{\s*transition: none;/u)
const responsive = stylesheet.split('@media (max-width: 1020px) {')[1]?.split('@media')[0]
expect(responsive).not.toMatch(/\.(?:new-chat|sidebar-search),/u)
})
it('floats conversation actions outside list layout with viewport limits', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;')
+2 -2
View File
@@ -1,5 +1,5 @@
import { useMemo, type RefObject } from 'react'
import { Settings } from 'lucide-react'
import { LayoutGrid } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
@@ -36,7 +36,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
})}
<div role="separator" />
<button type="button" role="menuitem" tabIndex={-1} onClick={() => { onClose(); anchorRef.current?.focus(); onManage() }}>
<Settings size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
<LayoutGrid size={16} aria-hidden="true" /><span>{t('applications.manage')}</span>
</button>
</AnchoredMenu>
}
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'
import type { ProjectCreateInput } from '../../shared/assistant-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
import {
repairChannelRuntimeSelection
compactRuntimeSelectionLayer,
resolveRuntimeChoice
} from '../../shared/runtime-selection-contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { SegmentedControl } from './WorkspacePrimitives'
@@ -13,15 +14,17 @@ export function channelProjectDraft(
project: ProjectCreateInput,
runtimeSettings: RuntimeSettings
): ProjectCreateInput {
const layer = compactRuntimeSelectionLayer(project.runtimeSelection)
return {
name: project.name,
description: project.description,
rootPath: project.rootPath,
defaultWorkMode: project.defaultWorkMode,
runtimeSelection: repairChannelRuntimeSelection(
project.runtimeSelection ?? { provider: 'auto' },
runtimeSettings
)
// Projects own their execution mode; fill in what older rows resolve to.
runtimeSelection: {
...layer,
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }).provider
}
}
}
@@ -96,9 +99,7 @@ export function ChannelProjectSettingsFields({
<small>{t('channels.project.rootHelp')}</small>
</label>
<ProjectRuntimeSelector
ariaLabel={t('channels.project.backendAriaLabel', {
name: value.name
})}
ariaLabelPrefix={value.name}
disabled={disabled}
label={t('channels.project.backendLabel')}
onChange={(runtimeSelection) =>
@@ -106,7 +107,7 @@ export function ChannelProjectSettingsFields({
}
runtimeSettings={runtimeSettings}
selection={value.runtimeSelection}
selectionMode="channel"
textOnly
/>
<fieldset className="project-work-mode">
<legend><span className="inline-help-label">{t('channels.project.defaultMode')}<InlineHelp label={t('channels.project.defaultMode')}>{t('channels.project.overrideHelp')}</InlineHelp></span></legend>
@@ -18,7 +18,6 @@ import type {
AssistantProject,
ProjectCreateInput
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import { ChannelSettingsSection } from './ChannelSettingsSection'
import i18n from './i18n'
@@ -114,7 +113,7 @@ const projects: AssistantProject[] = [
rootPath: 'C:\\Users\\tester'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'auto' },
runtimeSelection: { provider: 'model' },
kind: 'channel',
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
status: 'active',
@@ -239,13 +238,8 @@ describe('ChannelSettingsSection', () => {
fireEvent.change(screen.getByLabelText('企业微信 默认工作目录'), {
target: { value: 'C:\\RemoteWorkspace' }
})
fireEvent.change(screen.getByLabelText('企业微信 消息处理后端'), {
target: {
value: agentRuntimeSelectionKey({
provider: 'model',
profileId: directProfileId
})
}
fireEvent.change(screen.getByLabelText('企业微信 模型'), {
target: { value: directProfileId }
})
fireEvent.click(
within(
@@ -265,7 +259,7 @@ describe('ChannelSettingsSection', () => {
defaultWorkMode: 'execute',
runtimeSelection: {
provider: 'model',
profileId: directProfileId
model: { kind: 'profile', profileId: directProfileId }
}
})
)
@@ -495,56 +489,19 @@ describe('ChannelSettingsSection', () => {
})
renderChannelSettings()
const backend = await screen.findByLabelText(
'微信 ClawBot 消息处理后端'
)
expect(backend).toHaveValue(
agentRuntimeSelectionKey({
provider: 'model'
})
)
expect(
within(backend).queryByRole('option', {
name: /自动/u
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', {
name: '默认模型 · text-model'
})
).toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '图片模型 · image-model'
})
).not.toBeInTheDocument()
expect(
within(backend).queryByRole('option', {
name: '未配置模型 · missing-key-model'
})
).not.toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'OpenCode' })
).toBeInTheDocument()
expect(
within(backend).getByRole('option', { name: 'Continue' })
).toBeInTheDocument()
expect(
within(backend).getByRole('option', {
name: 'DeepSeek Harness(预览 · OpenAI 兼容)'
})
).toBeInTheDocument()
const backend = await screen.findByLabelText('微信 ClawBot 执行方式')
// Channel projects start on the direct model; the model follows global settings.
expect(backend).toHaveValue('model')
expect(within(backend).queryByRole('option', { name: /自动/u })).not.toBeInTheDocument()
for (const name of ['直连模型', 'OpenCode', 'Continue', 'DeepSeek Harness']) {
expect(within(backend).getByRole('option', { name })).toBeInTheDocument()
}
const model = screen.getByLabelText('微信 ClawBot 模型')
expect(within(model).getByRole('option', { name: '默认模型 · text-model' })).toBeInTheDocument()
expect(within(model).queryByRole('option', { name: /image-model/u })).not.toBeInTheDocument()
expect(within(model).getByRole('option', { name: /missing-key-model.*缺少 API Key/u })).toBeInTheDocument()
fireEvent.change(backend, {
target: {
value: agentRuntimeSelectionKey({ provider: 'opencode' })
}
})
expect(
screen.getByText(
'通过 OpenCode Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 OpenCode 配置。'
)
).toBeInTheDocument()
fireEvent.change(backend, { target: { value: 'opencode' } })
fireEvent.click(
screen.getByRole('button', { name: '保存通道设置' })
)
+159
View File
@@ -80,6 +80,74 @@ function createMessages(): Message[] {
}
describe('ChatTimeline', () => {
it.each(['cancelled', 'failed'] as const)('renders saved %s status and offers editing only with usable input', (terminalStatus) => {
const props = {
...callbacks, artifactById: new Map(), conversationId: 'terminal',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, totalMessageCount: 1
}
const message: Message = { id: 'reply', role: 'assistant', content: 'Existing reply',
state: 'error', terminalStatus, status: 'Terminal detail', createdAt: 1 }
const view = render(<ChatTimeline {...props} messages={[message]} />)
const status = screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' })
expect(status.classList.contains('message__status--error')).toBe(terminalStatus === 'failed')
expect(screen.getByText('Existing reply')).toBeVisible()
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
for (const extra of [
{ contextCompression: { state: 'completed' as const, estimatedBeforeTokens: 20_000, estimatedAfterTokens: 8_000 } },
{ knowledgeRetrieval: { mode: 'always' as const, state: 'succeeded' as const, libraryCount: 1,
resultCount: 2, durationMs: 20, usedChannels: ['fts' as const], warnings: [] } }
]) {
view.rerender(<ChatTimeline {...props} messages={[{ ...message, ...extra }]} />)
expect(screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' }))
.toHaveAttribute('aria-live', terminalStatus === 'cancelled' ? 'polite' : 'assertive')
}
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent=" " />)
expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument()
view.rerender(<ChatTimeline {...props} messages={[message]} retryContent="Original prompt" />)
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }))
expect(callbacks.onRetry).toHaveBeenLastCalledWith('Original prompt')
})
it.each(['请求已取消', 'Provider failed'])('deduplicates only the exact legacy fallback shape (%s) without removing reply text', (detail) => {
const props = { ...callbacks, artifactById: new Map(), conversationId: 'legacy',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, totalMessageCount: 1 }
const message: Message = { id: 'old', role: 'assistant', content: detail,
state: 'error', status: detail, createdAt: 1 }
const view = render(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getAllByText(detail)).toHaveLength(1)
expect(screen.getByText(detail).closest('.message__content')).not.toBeNull()
const blocks: Message['blocks'] = [
{ id: 'reasoning', type: 'reasoning', content: 'Preserved reasoning' },
{ id: 'fallback', type: 'text', content: message.content }
]
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks }]} />)
expect(screen.getAllByText(detail)).toHaveLength(1)
// A body that merely quotes or includes the status is never stripped.
view.rerender(<ChatTimeline {...props} messages={[{ ...message, content: `The message says ${detail}` }]} />)
expect(screen.getByText(`The message says ${detail}`)).toBeVisible()
const role = detail === '请求已取消' ? 'status' : 'alert'
expect(screen.getByRole(role, { name: detail }).classList.contains('message__status--error')).toBe(role === 'alert')
// Explicit failed events must override even the old cancellation labels.
view.rerender(<ChatTimeline {...props} messages={[{ ...message, terminalStatus: 'failed' }]} />)
expect(screen.getByRole('alert', { name: detail })).toHaveClass('message__status--error')
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
// Content and blocks disagree: do not treat this as the old fallback.
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: [{ id: 'real', type: 'text', content: 'Real output' }] }]} />)
expect(screen.getByText('Real output')).toBeVisible()
expect(screen.getByRole(role, { name: detail })).toBeVisible()
// Multiple text blocks or a non-final text block are not the old fallback.
for (const nextBlocks of [
[...blocks, { id: 'later', type: 'reasoning' as const, content: 'Later reasoning' }],
[{ id: 'earlier', type: 'text' as const, content: 'Earlier output' }, ...blocks]
]) {
view.rerender(<ChatTimeline {...props} messages={[{ ...message, blocks: nextBlocks }]} />)
expect(screen.getByRole(role, { name: detail })).toBeVisible()
expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible()
}
})
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
const onAddToNote = vi.fn()
const messages: Message[] = [
@@ -793,6 +861,97 @@ describe('ChatTimeline', () => {
}
})
it('reuses subagent derivations during streaming and invalidates only changed dependencies', () => {
const props = {
...callbacks, artifactById: new Map(), conversationId: 'subagent-stream',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, totalMessageCount: 1
}
const subagents: NonNullable<Message['subagents']> = ['a', 'b', 'c', 'd'].map(id => ({
childTaskId: id, expertId: id, expertName: `Expert ${id}`,
routingMode: 'native', state: 'running'
}))
const makeBlocks = (content: string): NonNullable<Message['blocks']> => [
{ id: 'b', type: 'subagent', childTaskId: 'b' },
{ id: 'missing', type: 'subagent', childTaskId: 'missing' },
{ id: 'text', type: 'text', content },
{ id: 'a', type: 'subagent', childTaskId: 'a' }
]
let message: Message = {
id: 'stream', role: 'assistant', content: 'Initial', createdAt: 0,
state: 'streaming', subagents, blocks: makeBlocks('Initial')
}
const map = vi.spyOn(subagents, 'map')
const filter = vi.spyOn(subagents, 'filter')
const blockFilters = [vi.spyOn(message.blocks!, 'filter')]
// Question IDs also filter blocks; count only passes returning subagent markers.
const counts = () => ({
map: map.mock.calls.length,
ordered: blockFilters.reduce((count, probe) => count + probe.mock.results.filter(
result => result.type === 'return' && result.value[0]?.type === 'subagent'
).length, 0),
unordered: filter.mock.calls.length
})
try {
const view = render(<ChatTimeline {...props} messages={[message]} />)
const cardNames = () => Array.from(view.container.querySelectorAll(
'.subagent-status-card__heading strong'
), element => element.textContent)
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
for (let index = 0; index < 3; index += 1) {
message = { ...message, status: `Streaming ${index}` }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByRole('status', { name: `Streaming ${index}` })).toBeVisible()
}
expect(counts()).toEqual({ map: 1, ordered: 1, unordered: 1 })
for (let index = 0; index < 3; index += 1) {
const content = `Streamed block ${index}`
message = { ...message, content, blocks: makeBlocks(content) }
blockFilters.push(vi.spyOn(message.blocks!, 'filter'))
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByText(content)).toBeVisible()
expect(markdownRenderProbe).toHaveBeenLastCalledWith(content)
expect(cardNames()).toEqual(['Expert b', 'Expert a', 'Expert c', 'Expert d'])
}
expect(counts()).toEqual({ map: 1, ordered: 4, unordered: 4 })
// Both anchored lookup and unanchored fallback must receive new child data.
message = { ...message, subagents: subagents.map(child => ({
...child, expertName: `Updated ${child.childTaskId}`, state: 'completed',
output: `Result ${child.childTaskId}`
})) }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated b', 'Updated a', 'Updated c', 'Updated d'])
for (const card of view.container.querySelectorAll<HTMLDetailsElement>('.subagent-status-card')) {
expect(card).toHaveClass('subagent-status-card--completed')
fireEvent.click(card.querySelector('summary')!)
fireEvent(card, new Event('toggle'))
const id = card.querySelector('strong')!.textContent!.slice(-1)
expect(within(card).getByText(`Result ${id}`)).toBeVisible()
}
// Move a fallback child into the ordered blocks without duplicating it.
message = { ...message, blocks: [{ id: 'd', type: 'subagent', childTaskId: 'd' }] }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated d', 'Updated a', 'Updated b', 'Updated c'])
for (const blocks of [undefined, []]) {
message = { ...message, blocks }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual(['Updated a', 'Updated b', 'Updated c', 'Updated d'])
}
message = { ...message, subagents: undefined }
view.rerender(<ChatTimeline {...props} messages={[message]} />)
expect(cardNames()).toEqual([])
} finally {
map.mockRestore()
filter.mockRestore()
blockFilters.forEach(probe => probe.mockRestore())
}
})
it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [
{
+27 -13
View File
@@ -41,6 +41,7 @@ import { AgentQuestionCard } from './AgentQuestionCard'
import { MarkdownRenderer } from './MarkdownRenderer'
import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
import { isCancelledMessage } from './message-terminal-status'
export type ToolActivity = ConversationToolActivity
@@ -61,6 +62,7 @@ export type Message = {
displayCaptureTruncated?: boolean
createdAt: number
state: 'streaming' | 'complete' | 'error'
terminalStatus?: ConversationMessage['terminalStatus']
status?: string
runtimeChecklist?: RuntimeChecklist
contextCompression?: ConversationMessage['contextCompression']
@@ -606,20 +608,20 @@ function ChatMessageRowView({
const compressionMarkers =
message.contextCompressions ??
(message.contextCompression ? [message.contextCompression] : [])
const subagentsById = new Map(
const subagentsById = useMemo(() => new Map(
(message.subagents ?? []).map((subagent) => [
subagent.childTaskId,
subagent
])
)
const orderedSubagentIds = new Set(
), [message.subagents])
const orderedSubagentIds = useMemo(() => new Set(
message.blocks
?.filter((block) => block.type === 'subagent')
.map((block) => block.childTaskId)
)
const unorderedSubagents = message.subagents?.filter(
), [message.blocks])
const unorderedSubagents = useMemo(() => message.subagents?.filter(
(subagent) => !orderedSubagentIds.has(subagent.childTaskId)
)
), [message.subagents, orderedSubagentIds])
const compressionLabel = (
compression: ConversationContextCompressionMarker
): string =>
@@ -706,7 +708,19 @@ function ChatMessageRowView({
subagent.state === 'queued' || subagent.state === 'running')
const activeCompression = compressionMarkers.find((marker) =>
marker.state === 'compressing')
const statusText = message.role === 'assistant' && message.state === 'streaming'
// Old snapshots may contain the fallback status as the entire reply. Keep
// the body (it could be genuine output) and omit only the duplicate status.
const duplicateLegacyStatus = message.role === 'assistant' &&
message.state === 'error' && message.terminalStatus === undefined &&
Boolean(message.status) && message.content === message.status &&
(!message.blocks?.length || (
message.blocks.at(-1)?.type === 'text' &&
message.blocks.every(block => block.type !== 'text' ||
(block === message.blocks?.at(-1) && block.content === message.status))
))
const failed = message.state === 'error' && !isCancelledMessage(message)
const statusText = duplicateLegacyStatus ? undefined
: message.role === 'assistant' && message.state === 'streaming'
? question
? t('chat.status.waitingForAnswer')
: message.approval
@@ -1233,21 +1247,21 @@ function ChatMessageRowView({
aria-atomic="true"
aria-label={statusText}
aria-live={
message.knowledgeRetrieval || compressionMarkers.length > 0
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
? undefined
: message.state === 'error'
: failed
? 'assertive'
: 'polite'
}
className={
message.state === 'error'
failed
? 'message__status message__status--error'
: 'message__status'
}
role={
message.knowledgeRetrieval || compressionMarkers.length > 0
message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0)
? undefined
: message.state === 'error'
: failed
? 'alert'
: 'status'
}
@@ -1259,7 +1273,7 @@ function ChatMessageRowView({
{statusText}
</div>
)}
{canRetry && (
{canRetry && Boolean(retryContent?.trim()) && (
<button
className="message-retry"
onClick={() => {
+24
View File
@@ -55,6 +55,13 @@ describe('compact notes', () => {
expect(screen.queryByText('Text to add')).not.toBeInTheDocument()
expect(screen.queryByText('Global')).not.toBeInTheDocument()
expect(create).not.toHaveBeenCalled()
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
expect(screen.queryByText(/excluding reasoning/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'What gets saved' }))
expect(screen.getByRole('tooltip')).toHaveTextContent('excluding reasoning')
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'New note' }))
expect(screen.getByLabelText('Note title')).toHaveValue('Discussion A')
expect(document.getElementById('compact-note-validation')).not.toBeInTheDocument()
@@ -162,11 +169,28 @@ describe('compact notes', () => {
expect(preview).toHaveValue(oversized)
expect(screen.getByRole('button', { name: 'Add to note' })).toBeDisabled()
expect(screen.getByText(/Each entry supports up to 500 KB/)).toBeVisible()
expect(preview).toHaveAccessibleDescription(/Each entry supports up to 500 KB/)
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
fireEvent.change(preview, { target: { value: 'Within capacity' } })
expect(screen.getByRole('button', { name: 'Add to note' })).toBeEnabled()
expect(create).not.toHaveBeenCalled()
})
it('keeps new-note validation beside the title without initial footer instructions', async () => {
render(<Harness />)
fireEvent.click(await screen.findByRole('button', { name: 'New note' }))
const title = screen.getByRole('textbox', { name: 'Note title' })
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
expect(screen.getByRole('button', { name: 'Create note' })).toBeDisabled()
fireEvent.change(title, { target: { value: 'x'.repeat(101) } })
expect(title).toHaveAccessibleDescription('Use a title of up to 100 characters.')
expect(title).toHaveAttribute('aria-invalid', 'true')
expect(document.querySelector('.magic-note-panel__footer p')).toBeNull()
fireEvent.change(title, { target: { value: 'Valid title' } })
expect(screen.queryByText('Use a title of up to 100 characters.')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create note' })).toBeEnabled()
})
it('preserves text when the target disappears and requires a new selection', async () => {
render(<Harness />)
fireEvent.click(screen.getByText('Capture'))
+22 -13
View File
@@ -7,6 +7,7 @@ import { MagicNoteSource, type OpenMagicNoteSource } from './MagicNoteSource'
import type { useMagicNoteDraft } from './use-magic-note-draft'
import type { AppNotificationInput } from './notifications'
import { SegmentedControl } from './WorkspacePrimitives'
import { InlineHelp } from './InlineHelp'
import './magic-notes-panel.css'
const MagicCanvasThumbnail = lazy(async () => ({ default: (await import('./MagicCanvasThumbnail')).MagicCanvasThumbnail }))
@@ -149,8 +150,8 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
setSelectedNoteId('')
requestAnimationFrame(() => document.getElementById('compact-note-search')?.focus())
}
const validationMessage = tooLong ? t('capture.tooLong') : draft && !valid ? t('capture.required') : undefined
const validationId = [validationMessage && 'compact-note-validation', saveError && 'compact-note-save-error'].filter(Boolean).join(' ') || undefined
const validationId = tooLong ? 'compact-note-validation' : undefined
const titleTooLong = Boolean(draft?.newNote && draft.title.trim().length > 100)
const list = (
<>
<label htmlFor="compact-note-search">{t('capture.search')}</label>
@@ -202,13 +203,18 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
{draft && !quickDraft ? (
<>
{draft.source && <div className="magic-note-source">
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
<p>{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}</p>
<div className="magic-note-panel__source-heading">
<span>{draft.source.projectName && `${draft.source.projectName} / `}{draft.source.conversationTitle}</span>
<InlineHelp label={t('capture.scopeHelp')}>
{t(draft.source.kind === 'message' ? 'capture.messageScope' : 'capture.conversationScope')}
</InlineHelp>
</div>
{draft.incomplete && <p>{t('capture.incomplete')}</p>}
</div>}
<textarea ref={preview} id="compact-note-text" aria-label={t('capture.content')} value={draft.text} disabled={saving}
aria-describedby={validationId} aria-invalid={tooLong || undefined}
onChange={event => setDraft({ ...draft, text: event.target.value })} />
{tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
<h3>{t('capture.target')}</h3>
<SegmentedControl ariaLabel={t('capture.target')} disabled={saving}
value={draft.newNote ? 'new' : 'existing'}
@@ -218,8 +224,9 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
<>
<label htmlFor="compact-note-title">{t('capture.title')}</label>
<input ref={title} id="compact-note-title" value={draft.title} disabled={saving}
aria-describedby={validationId} aria-invalid={draft.title.trim().length > 100 || undefined}
aria-describedby={titleTooLong ? 'compact-note-title-error' : undefined} aria-invalid={titleTooLong || undefined}
onChange={event => setDraft({ ...draft, title: event.target.value })} />
{titleTooLong && <p id="compact-note-title-error">{t('capture.titleTooLong')}</p>}
</>
) : (
<>
@@ -264,14 +271,16 @@ export function MagicNotesPanel({ state, active, commentMode, commentFormat = 'c
text: event.target.value, initialText: '', title: '', initialTitle: '', targetId: currentDetail.id, newNote: false
})} />
</>}
{validationMessage && <p id="compact-note-validation">{validationMessage}</p>}
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
<div className="magic-note-panel__actions">
{draft && <button className="secondary-button" type="button" disabled={saving}
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
</button>
{quickDraft && tooLong && <p id="compact-note-validation">{t('capture.tooLong')}</p>}
<div className="magic-note-panel__submit">
{saveError && <p role="alert" id="compact-note-save-error">{saveError}</p>}
<div className="magic-note-panel__actions">
{draft && <button className="secondary-button" type="button" disabled={saving}
onClick={() => void cancel()}>{t('actions.cancel')}</button>}
<button className="primary-button" type="button" disabled={saving || !valid} onClick={() => void save()}>
{t(saving ? 'capture.saving' : draft?.newNote && !draft.text.trim() && !draft.source ? 'actions.createNote' : 'capture.add')}
</button>
</div>
</div>
</footer>
)}
+22 -1
View File
@@ -17,7 +17,8 @@ import {
import { changeUiLocale } from './i18n'
import {
InlineMarkdown,
MarkdownRenderer
MarkdownRenderer,
normalizeLatexDelimiters
} from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
@@ -31,6 +32,26 @@ vi.mock('mermaid', () => ({
}))
describe('MarkdownRenderer', () => {
it.each([
['# Title\n\n**bold** `code`\n', '# Title\n\n**bold** `code`\n'],
['plain \\', 'plain \\'],
['partial \\(x', 'partial $x'],
['partial x\\)', 'partial x$'],
['\\(x\\)', '$$\nx\n$$'],
['\\[x\\]', '$$\nx\n$$'],
['$x$', '$$\nx\n$$'],
['`\\(code\\)` then \\(x\\)', '`\\(code\\)` then $x$'],
['~~~tex\n$x$\n\\(x\\)\n~~~', '~~~tex\n$x$\n\\(x\\)\n~~~']
])('preserves normalization across fast paths: %j', (content, expected) => {
expect(normalizeLatexDelimiters(content)).toBe(expected)
})
it('preserves CRLF conversion inside code and standalone HTML without math', () => {
expect(normalizeLatexDelimiters('```txt\r\na\r\n```')).toBe('```txt\na\n```')
expect(normalizeLatexDelimiters('<section>\r\na\r\n</section>')).toBe('<section>\na\n</section>')
expect(normalizeLatexDelimiters('a\rb\r')).toBe('a\rb\r')
})
beforeEach(() => {
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: {
app: { onPreviewEscape: vi.fn((listener: () => void) => {
+8 -1
View File
@@ -134,6 +134,9 @@ const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
function replaceLatexDelimiters(line: string): string {
if (!line.includes('\\(') && !line.includes('\\)')) {
return line
}
let output = ''
let index = 0
let codeDelimiterLength = 0
@@ -171,7 +174,11 @@ function replaceLatexDelimiters(line: string): string {
return output
}
function normalizeLatexDelimiters(content: string): string {
export function normalizeLatexDelimiters(content: string): string {
// Without math markers or CRLF, every line would be returned unchanged.
if (!/[$\\\r]/u.test(content)) {
return content
}
let fence:
| {
character: '`' | '~'
@@ -1,51 +1,75 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultRuntimeSettings, type RuntimeSettings } from '../../shared/contracts'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import i18n from './i18n'
afterEach(cleanup)
beforeEach(async () => { await i18n.changeLanguage('zh-CN') })
describe('ProjectRuntimeSelector saved model references', () => {
it.each(['zh-CN', 'en-US'])('describes fixed selections accurately in %s without restoring duplicate menus', async (language) => {
await i18n.changeLanguage(language)
const profileId = '00000000-0000-4000-8000-000000000011'
const settings = {
...defaultRuntimeSettings,
defaultModelProfileId: profileId,
modelProfiles: [{
id: profileId, name: 'Model A', modelName: 'model-a',
baseUrl: 'https://example.com/v1', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true
}]
} as RuntimeSettings
const firstId = '00000000-0000-4000-8000-000000000011'
const secondId = '00000000-0000-4000-8000-000000000012'
const settings = {
...defaultRuntimeSettings,
provider: 'opencode',
defaultModelProfileId: firstId,
opencodeModelSource: { kind: 'profile', profileId: firstId },
continueModelSource: { kind: 'platform' },
modelProfiles: [
{ id: firstId, name: 'Model A', modelName: 'model-a', baseUrl: 'https://example.com/v1',
protocol: 'openai-chat-completions', authentication: 'api-key', apiKeyConfigured: true },
{ id: secondId, name: 'Model B', modelName: 'model-b', baseUrl: 'https://example.com/v1',
protocol: 'anthropic-messages', authentication: 'none' }
]
} as RuntimeSettings
describe('ProjectRuntimeSelector', () => {
it('shows a concrete execution mode and follows that Runtime’s default model', () => {
render(<ProjectRuntimeSelector label="Runtime" onChange={vi.fn()} runtimeSettings={settings}
selection={{ provider: 'opencode' }} />)
const provider = screen.getByLabelText('执行方式') as HTMLSelectElement
expect(provider).toHaveValue('opencode')
// No "follow global" execution mode: projects own it.
expect(Array.from(provider.options).map((option) => option.value))
.toEqual(['model', 'opencode', 'continue', 'deepseek-harness'])
expect(screen.getByLabelText('模型')).toHaveValue('')
expect(screen.getByRole('option', { name: '跟随 OpenCode 默认(Model A)' })).toBeInTheDocument()
expect(screen.getByText('模型来自全局设置')).toBeInTheDocument()
})
it('switches mode and model and returns the model to the Runtime default', () => {
const onChange = vi.fn()
for (const provider of ['opencode', 'continue', 'deepseek-harness'] as const) {
const runtime = provider === 'opencode' ? 'OpenCode'
: provider === 'continue' ? 'Continue' : 'DeepSeek Harness'
const props = {
ariaLabel: 'Project Runtime', label: 'Runtime', onChange,
runtimeSettings: settings
}
const view = render(<ProjectRuntimeSelector {...props} selection={{ provider, profileId }} />)
const select = screen.getByRole('combobox')
expect(select).toHaveValue(`${provider}:${profileId}`)
expect(screen.getByText(language === 'zh-CN'
? `通过 ${runtime} 运行,固定使用 Model A。`
: `Run through ${runtime} with the fixed model Model A.`)).toBeInTheDocument()
expect(screen.getAllByRole('option').filter((option) =>
(option as HTMLOptionElement).value.endsWith(`:${profileId}`))).toHaveLength(2)
const view = render(<ProjectRuntimeSelector label="Runtime" onChange={onChange}
runtimeSettings={settings} selection={{ provider: 'opencode' }} />)
fireEvent.change(screen.getByLabelText('执行方式'), { target: { value: 'continue' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'continue' })
view.rerender(<ProjectRuntimeSelector label="Runtime" onChange={onChange}
runtimeSettings={settings} selection={{ provider: 'continue' }} />)
expect(screen.getByRole('option', { name: '跟随 Continue 默认(自有配置)' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('模型'), { target: { value: secondId } })
expect(onChange).toHaveBeenLastCalledWith({
provider: 'continue', model: { kind: 'profile', profileId: secondId }
})
view.rerender(<ProjectRuntimeSelector label="Runtime" onChange={onChange} runtimeSettings={settings}
selection={{ provider: 'continue', model: { kind: 'profile', profileId: secondId } }} />)
fireEvent.change(screen.getByLabelText('模型'), { target: { value: '' } })
expect(onChange).toHaveBeenLastCalledWith({ provider: 'continue' })
})
fireEvent.change(select, { target: { value: `${provider}:default` } })
expect(onChange).toHaveBeenLastCalledWith({ provider })
view.rerender(<ProjectRuntimeSelector {...props} selection={{ provider }} />)
expect(select).toHaveValue(`${provider}:default`)
expect(screen.queryByText(language === 'zh-CN'
? `通过 ${runtime} 运行,固定使用 Model A。`
: `Run through ${runtime} with the fixed model Model A.`)).not.toBeInTheDocument()
expect(screen.getAllByRole('option').filter((option) =>
(option as HTMLOptionElement).value.endsWith(`:${profileId}`))).toHaveLength(1)
view.unmount()
}
it('explains and offers to clear a deleted model', () => {
const onChange = vi.fn()
render(<ProjectRuntimeSelector label="Runtime" onChange={onChange} runtimeSettings={settings}
selection={{ provider: 'opencode', model: { kind: 'profile', profileId: '00000000-0000-4000-8000-000000000099' } }} />)
expect(screen.getByText('所选模型连接已删除,已回退到 Model A')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '改为跟随默认模型' }))
expect(onChange).toHaveBeenLastCalledWith({ provider: 'opencode' })
})
it('limits remote projects to OpenCode and Continue without own configuration', () => {
render(<ProjectRuntimeSelector label="Runtime" onChange={vi.fn()} remote runtimeSettings={settings} />)
const providers = Array.from((screen.getByLabelText('执行方式') as HTMLSelectElement).options)
.map((option) => option.value)
expect(providers).toEqual(['opencode', 'continue'])
expect(screen.queryByRole('option', { name: /自有配置/u })).not.toBeInTheDocument()
})
})
+190 -185
View File
@@ -1,212 +1,217 @@
import type { TFunction } from 'i18next'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import type { RuntimeSettings } from '../../shared/contracts'
import {
agentRuntimeSelectionKey,
getRuntimeSelectionForProvider,
isChannelModelProfileUsable,
repairChannelRuntimeSelection,
type AgentRuntimeSelection
allowedRuntimeProviders,
compactRuntimeSelectionLayer,
globalDefaultModel,
isResolvableStatus,
resolveRuntimeChoice,
runtimeModelChoiceStatus,
selectableModelProfiles,
supportsRuntimeConfig,
type AgentRuntimeProvider,
type RuntimeModelChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
ignoredChoiceMessage,
runtimeModelLabel,
runtimeProviderLabel,
sourceLabel
} from './runtime-selection'
type ProjectRuntimeSelectorProps = {
ariaLabel: string
disabled?: boolean
label: string
onChange: (selection: AgentRuntimeSelection) => void
help?: string
onChange: (selection: RuntimeSelectionLayer | undefined) => void
runtimeSettings: RuntimeSettings
selection?: AgentRuntimeSelection
selectionMode?: 'configured' | 'channel'
selection?: RuntimeSelectionLayer
/** Managed SSH projects accept only OpenCode and Continue with a GoodBuddy connection. */
remote?: boolean
/** Prefix for accessible names when several projects are edited on one page. */
ariaLabelPrefix?: string
/** Unattended channel projects cannot use image-generation connections. */
textOnly?: boolean
}
function runtimeSelectionDescription(
selection: AgentRuntimeSelection,
settings: RuntimeSettings,
t: TFunction<'integrations'>
): string {
if (selection.provider === 'model') {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === (selection.profileId ?? settings.defaultModelProfileId)
)
if (!profile) {
return t('channels.project.missingSelection')
}
if (profile.protocol === 'openai-images-generations') {
return t('channels.project.imageOnlySelection')
}
if (
profile.authentication === 'api-key' &&
!profile.apiKeyConfigured
) {
return t('channels.project.missingCredential')
}
return t('channels.project.directDescription', {
name: profile.name,
modelName: profile.modelName
})
}
if (selection.provider === 'auto') {
return t('channels.project.automaticDescription')
}
const runtimeLabel =
selection.provider === 'opencode'
? 'OpenCode'
: selection.provider === 'continue'
? 'Continue'
: 'DeepSeek Harness'
if (selection.profileId) {
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId
)
return t('channels.project.fixedRuntimeDescription', {
runtime: runtimeLabel,
name: profile?.name ?? t('channels.project.missingProfile')
})
}
return t('channels.project.runtimeDescription', {
runtime: runtimeLabel
})
const INHERIT = ''
const RUNTIME_CONFIG = 'runtime-config'
function modelValue(model: RuntimeModelChoice | undefined): string {
if (!model) return INHERIT
return model.kind === 'profile' ? model.profileId : RUNTIME_CONFIG
}
/**
* Project layer editor: an execution mode and a model, each defaulting to
* "Follow global (current value)". Saving keeps inheritance instead of
* freezing today's global value.
*/
export function ProjectRuntimeSelector({
ariaLabel,
disabled = false,
label,
help,
onChange,
runtimeSettings,
selection,
selectionMode = 'configured'
remote = false,
ariaLabelPrefix,
textOnly = false
}: ProjectRuntimeSelectorProps): React.JSX.Element {
const { t } = useTranslation('integrations')
const initialSelection =
selection ?? { provider: 'auto' as const }
const runtimeSelection =
selectionMode === 'channel'
? repairChannelRuntimeSelection(initialSelection, runtimeSettings)
: initialSelection
const directProfiles = runtimeSettings.modelProfiles.filter(
isChannelModelProfileUsable
)
const selectedDirectProfileId =
runtimeSelection.provider === 'model'
? runtimeSelection.profileId
: undefined
const selectedDirectProfile = runtimeSettings.modelProfiles.find(
(profile) => profile.id === selectedDirectProfileId
)
const selectedDirectUnavailable =
selectedDirectProfileId !== undefined &&
!directProfiles.some(
(profile) => profile.id === selectedDirectProfileId
)
const runtimeProviders = [
'opencode',
'continue',
'deepseek-harness'
] as const
const runtimeSelections = runtimeProviders.map((provider) => {
return selectionMode === 'channel'
? ({ provider } as AgentRuntimeSelection)
: getRuntimeSelectionForProvider(provider, runtimeSettings)
})
const directSelections = directProfiles.map((profile) => ({
provider: 'model' as const,
profileId: profile.id
}))
const inheritedSelections: AgentRuntimeSelection[] = [
...(selectionMode === 'configured' ? [{ provider: 'auto' as const }] : []),
{ provider: 'model' }
]
const fixedRuntimeSelection = runtimeSelection.provider !== 'model' &&
'profileId' in runtimeSelection && runtimeSelection.profileId ? runtimeSelection : undefined
const selections = [...inheritedSelections, ...directSelections, ...runtimeSelections,
...(fixedRuntimeSelection ? [fixedRuntimeSelection] : [])]
const selectionByKey = new Map(
selections.map((candidate) => [
agentRuntimeSelectionKey(candidate),
candidate
])
const { t } = useTranslation('app')
const id = useId()
const options = { remote }
const layer = compactRuntimeSelectionLayer(selection)
const resolved = resolveRuntimeChoice(runtimeSettings, { project: layer }, options)
const provider = resolved.provider
const inheritedModel = globalDefaultModel(provider, runtimeSettings, options)
const profiles = selectableModelProfiles(provider, runtimeSettings).filter(
(profile) => !textOnly || profile.protocol !== 'openai-images-generations'
)
const storedModel = layer?.model
const storedModelStatus = storedModel
? runtimeModelChoiceStatus(provider, storedModel, runtimeSettings, options)
: 'ok'
const warning = ignoredChoiceMessage(resolved, runtimeSettings, t)
const accessible = (name: string): string =>
ariaLabelPrefix ? `${ariaLabelPrefix} ${name}` : name
const update = (next: RuntimeSelectionLayer): void => {
onChange(compactRuntimeSelectionLayer(next))
}
const changeProvider = (value: string): void => {
const nextProvider = value as AgentRuntimeProvider
const effective = nextProvider
// A model chosen for another execution mode rarely applies; keep it only when valid.
const keepModel =
storedModel &&
runtimeModelChoiceStatus(effective, storedModel, runtimeSettings, options) === 'ok'
update({
provider: nextProvider,
...(keepModel ? { model: storedModel } : {})
})
}
const changeModel = (value: string): void => {
update({
...(layer?.provider ? { provider: layer.provider } : {}),
...(value === INHERIT
? {}
: value === RUNTIME_CONFIG
? { model: { kind: 'runtime-config' as const } }
: { model: { kind: 'profile' as const, profileId: value } })
})
}
const providerLabel = t('runtimeSelection.providerLabel')
const modelLabel = t('runtimeSelection.modelLabel')
const effectiveProvider = runtimeProviderLabel(resolved.provider, t)
const effectiveModel = runtimeModelLabel(resolved.provider, resolved.model, runtimeSettings, t)
const overridden = resolved.modelSource !== 'global'
return (
<label className="field project-runtime-selector">
<span>{label}</span>
<select
aria-label={ariaLabel}
disabled={disabled}
onChange={(event) => {
const nextSelection = selectionByKey.get(event.target.value)
if (nextSelection) {
onChange(nextSelection)
}
}}
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{inheritedSelections.map((candidate) => (
<option key={candidate.provider} value={agentRuntimeSelectionKey(candidate)}>
{t(candidate.provider === 'auto' ? 'channels.project.automaticDescription' : 'channels.project.defaultDirect')}
</option>
))}
{fixedRuntimeSelection && (
<option value={agentRuntimeSelectionKey(fixedRuntimeSelection)}>
{fixedRuntimeSelection.provider} · {runtimeSettings.modelProfiles.find((profile) => profile.id === fixedRuntimeSelection.profileId)?.name ?? t('channels.project.missingProfile')}
</option>
)}
<optgroup label={t('channels.project.directModels')}>
{selectedDirectUnavailable && (
<option
disabled
value={agentRuntimeSelectionKey(runtimeSelection)}
>
{selectedDirectProfile
? t('channels.project.unavailableProfile', {
name: selectedDirectProfile.name,
modelName: selectedDirectProfile.modelName
})
: t('channels.project.missingProfile')}
</option>
)}
{directProfiles.length === 0 && (
<option disabled value="model:unavailable">
{t('channels.project.noTextModels')}
</option>
)}
{directSelections.map((candidate) => {
const profile = directProfiles.find(
(item) => item.id === candidate.profileId
)!
return (
<option
key={profile.id}
value={agentRuntimeSelectionKey(candidate)}
>
{profile.name} · {profile.modelName}
<fieldset className="project-runtime-selector" disabled={disabled}>
<legend>{label}</legend>
<div className="project-runtime-selector__fields">
<label className="field" htmlFor={`${id}-provider`}>
<span>{providerLabel}</span>
<select
aria-label={accessible(providerLabel)}
id={`${id}-provider`}
onChange={(event) => changeProvider(event.target.value)}
// The project owns its execution mode; older projects show what they resolve to.
value={provider}
>
{allowedRuntimeProviders(options).map((candidate) => (
<option key={candidate} value={candidate}>
{runtimeProviderLabel(candidate, t)}
</option>
)
})}
</optgroup>
<optgroup label="Agent Runtime">
{runtimeSelections.map((candidate) => (
<option
key={candidate.provider}
value={agentRuntimeSelectionKey(candidate)}
>
{candidate.provider === 'opencode'
? 'OpenCode'
: candidate.provider === 'continue'
? 'Continue'
: t('channels.project.deepseekHarnessOption')}
))}
</select>
</label>
<label className="field" htmlFor={`${id}-model`}>
<span>{modelLabel}</span>
<select
aria-label={accessible(modelLabel)}
id={`${id}-model`}
onChange={(event) => changeModel(event.target.value)}
value={modelValue(storedModel)}
>
<option value={INHERIT}>
{t('runtimeSelection.followRuntimeDefault', {
runtime: runtimeProviderLabel(provider, t),
value: runtimeModelLabel(provider, inheritedModel, runtimeSettings, t)
})}
</option>
))}
</optgroup>
</select>
<small>
{runtimeSelectionDescription(
runtimeSelection,
runtimeSettings,
t
)}
</small>
</label>
{storedModel && !isResolvableStatus(storedModelStatus) && (
<option disabled value={modelValue(storedModel)}>
{storedModel.kind === 'profile'
? `⚠ ${runtimeModelLabel(provider, storedModel, runtimeSettings, t)}`
: `⚠ ${t('runtimeSelection.runtimeConfig', { runtime: runtimeProviderLabel(provider, t) })}`}
</option>
)}
{supportsRuntimeConfig(provider, options) && (
<option value={RUNTIME_CONFIG}>
{t('runtimeSelection.runtimeConfig', {
runtime: runtimeProviderLabel(provider, t)
})}
</option>
)}
{profiles.map((profile) => {
const status = runtimeModelChoiceStatus(
provider,
{ kind: 'profile', profileId: profile.id },
runtimeSettings,
options
)
return (
<option
disabled={!isResolvableStatus(status) && profile.id !== modelValue(storedModel)}
key={profile.id}
value={profile.id}
>
{profile.name}
{profile.modelName ? ` · ${profile.modelName}` : ''}
{status === 'unavailable' ? t('runtimeSelection.unavailableSuffix') : ''}
</option>
)
})}
</select>
</label>
</div>
<p
className={`project-runtime-selector__summary${overridden ? ' project-runtime-selector__summary--override' : ''}`}
>
<span>
{t('runtimeSelection.effective', {
provider: effectiveProvider,
model: effectiveModel
})}
</span>
<small>
{t('runtimeSelection.effectiveModelSource', {
model: sourceLabel(resolved.modelSource, t)
})}
</small>
</p>
{warning && (
<p className="project-runtime-selector__warning" role="status">
<span>{warning}</span>
{storedModel && !isResolvableStatus(storedModelStatus) && (
<button
className="secondary-button"
onClick={() => changeModel(INHERIT)}
type="button"
>
{t('runtimeSelection.followGlobalAction')}
</button>
)}
</p>
)}
{help && <small className="project-runtime-selector__scope-help">{help}</small>}
</fieldset>
)
}
+12 -38
View File
@@ -13,7 +13,6 @@ import {
defaultRuntimeSettings,
type RuntimeSettings
} from '../../shared/contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
import type {
SshDirectoryBrowseResult,
SshHost,
@@ -66,7 +65,7 @@ const project: AssistantProject = {
rootPath: 'C:\\Workspace'
},
defaultWorkMode: 'ask',
runtimeSelection: { provider: 'model', profileId },
runtimeSelection: { provider: 'model', model: { kind: 'profile', profileId } },
kind: 'user',
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
@@ -413,21 +412,10 @@ describe('ProjectSwitcher runtime fields', () => {
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: 'Harness project' }
})
const runtime = within(dialog).getByLabelText(
'新对话默认 Runtime'
)
expect(
within(runtime).getByRole('option', {
name: 'DeepSeek Harness(预览 · OpenAI 兼容)'
})
).toBeInTheDocument()
fireEvent.change(runtime, {
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
})
const runtime = within(dialog).getByLabelText('执行方式')
// New projects start from a concrete execution mode.
expect(runtime).not.toHaveValue('')
fireEvent.change(runtime, { target: { value: 'deepseek-harness' } })
fireEvent.click(
within(dialog).getByRole('button', { name: '创建' })
)
@@ -450,16 +438,9 @@ describe('ProjectSwitcher runtime fields', () => {
name: '管理项目 Local project'
}))
const dialog = screen.getByRole('dialog', { name: '项目设置' })
fireEvent.change(
within(dialog).getByLabelText('新对话默认 Runtime'),
{
target: {
value: agentRuntimeSelectionKey({
provider: 'deepseek-harness'
})
}
}
)
fireEvent.change(within(dialog).getByLabelText('执行方式'), {
target: { value: 'deepseek-harness' }
})
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
@@ -494,11 +475,8 @@ describe('ProjectSwitcher runtime fields', () => {
fireEvent.click(screen.getByLabelText('New project'))
const dialog = screen.getByRole('dialog', { name: 'New project' })
expect(
within(dialog).getByLabelText(
'Default Runtime for new conversations'
)
).toBeInTheDocument()
expect(within(dialog).getByRole('group', { name: 'Default Runtime for new conversations' })).toBeInTheDocument()
expect(within(dialog).getByLabelText('Execution mode')).not.toHaveValue('')
expect(
within(dialog)
.getAllByRole('group', { name: 'Default mode' })
@@ -506,11 +484,7 @@ describe('ProjectSwitcher runtime fields', () => {
candidate.classList.contains('segmented-control')
)
).toBeInTheDocument()
expect(
within(dialog).getByRole('option', {
name: 'DeepSeek Harness (Preview · OpenAI-compatible)'
})
).toBeInTheDocument()
expect(within(dialog).getByRole('option', { name: 'DeepSeek Harness' })).toBeInTheDocument()
})
})
@@ -1291,7 +1265,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.change(within(dialog).getByRole('combobox', { name: '新对话默认 Runtime' }), {
fireEvent.change(within(dialog).getByRole('combobox', { name: '执行方式' }), {
target: { value: provider }
})
const modeGroup = within(dialog)
+42 -49
View File
@@ -43,8 +43,9 @@ import type {
} from '../../shared/remote-project-recovery-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import {
getDefaultRuntimeSelection
} from './runtime-selection'
resolveRuntimeChoice,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import {
channelProjectDraft,
ChannelProjectSettingsFields
@@ -889,6 +890,18 @@ export function ProjectSwitcher({
setSettingsProjectId(undefined)
}
// The project form always shows a concrete execution mode, so save exactly that mode.
const withProjectProvider = (
layer: RuntimeSelectionLayer | undefined,
remote: boolean
): RuntimeSelectionLayer | undefined =>
runtimeSettings
? {
...layer,
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }, { remote }).provider
}
: layer
const openProjectSettings = (project: AssistantProject): void => {
if (
!remoteProjectsEnabled &&
@@ -917,11 +930,8 @@ export function ProjectSwitcher({
defaultWorkMode: normalizeInteractiveWorkMode(
project.defaultWorkMode
),
runtimeSelection:
project.runtimeSelection ??
(runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined)
// Keep "follow global" as absent so saving never freezes today's default.
runtimeSelection: project.runtimeSelection
}
setDraft(
project.kind === 'channel' && runtimeSettings
@@ -965,10 +975,7 @@ export function ProjectSwitcher({
const commonDraft = {
name: draft.name,
description: draft.description,
runtimeSelection:
draft.runtimeSelection?.provider === 'opencode' || draft.runtimeSelection?.provider === 'continue'
? draft.runtimeSelection
: ({ provider: 'opencode' } as const),
runtimeSelection: withProjectProvider(draft.runtimeSelection, true),
hostId: remoteHostId,
remoteRootPath
}
@@ -1010,13 +1017,10 @@ export function ProjectSwitcher({
setSaving(true)
setError(undefined)
try {
const input =
draft.runtimeSelection || !runtimeSettings
? draft
: {
...draft,
runtimeSelection: getDefaultRuntimeSelection(runtimeSettings)
}
const input = {
...draft,
runtimeSelection: withProjectProvider(draft.runtimeSelection, false)
}
if (dialogMode === 'settings' && settingsProject) {
await onUpdate(settingsProject.id, input)
} else {
@@ -1394,10 +1398,7 @@ export function ProjectSwitcher({
name: '',
description: '',
rootPath: '',
defaultWorkMode: 'ask',
runtimeSelection: runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings)
: undefined
defaultWorkMode: 'ask'
})
restoreFocusTarget.current = 'create'
setDialogMode('create')
@@ -1597,10 +1598,10 @@ export function ProjectSwitcher({
{runtimeSettings && (
<>
<ProjectRuntimeSelector
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
disabled={busy}
help={t(
'projectSwitcher.dialog.defaultRuntimeHelp'
)}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
@@ -1613,11 +1614,6 @@ export function ProjectSwitcher({
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
<small className="project-runtime-selector__scope-help">
{t(
'projectSwitcher.dialog.defaultRuntimeHelp'
)}
</small>
</>
)}
</>
@@ -1748,27 +1744,24 @@ export function ProjectSwitcher({
{t('projectSwitcher.remote.rootHelp')}
</small>
</label>
<label className="remote-project-runtime">
<span>
{t(
{runtimeSettings && (
<ProjectRuntimeSelector
disabled={remoteFieldsDisabled}
help={t('projectSwitcher.remote.runtimeHelp')}
label={t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
</span>
<select aria-label={t('projectSwitcher.dialog.fields.defaultRuntime')}
disabled={remoteFieldsDisabled} value={draft.runtimeSelection?.provider ?? 'opencode'}
onChange={event => {
const provider = event.target.value === 'continue' ? 'continue' : 'opencode'
setDraft(current => ({ ...current, runtimeSelection: { provider } }))
}}>
<option value="opencode">OpenCode</option>
<option value="continue">Continue</option>
</select>
<small>
{t(
'projectSwitcher.remote.runtimeHelp'
)}
</small>
</label>
onChange={(runtimeSelection) =>
setDraft((current) => ({
...current,
runtimeSelection
}))
}
remote
runtimeSettings={runtimeSettings}
selection={draft.runtimeSelection}
/>
)}
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
@@ -639,7 +639,6 @@ function BrowserToolbar({
type="submit"
>
<ArrowRight aria-hidden="true" size={13} />
<span>{t('sidebar.browser.toolbar.go')}</span>
</button>
<button
aria-label={

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