From b77fb9036ce3296610cdcccd8a0e562472d35262 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 22:35:26 +0800 Subject: [PATCH] refactor(agent-presets,web): copy-only preset authoring with a path to the files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web YAML editor is gone. agentPreset.write (arbitrary composition text) became agentPreset.copy { from, agentPreset, name? }: a host-side whole-directory copy of ids the host resolves itself — symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept, metadata rewritten to keep the source's description but never its name or roster order. No composition text or path crosses the wire in either authoring direction, and the entryListSchema/!!js concern dissolves with assertComposition itself. The settings section becomes: a read-only viewer over shipped compositions, a copy dialog (id + optional display name) as the only create entry, delete for custom rows, and a location action leading into the preset's own files — agentPreset.openDocument { agentPreset } resolves the directory host-side and opens it natively, or answers { opened: false, path } for the row to show as text where the deployment has no desktop. agentPreset.list reports hasDocument beside authorable; the gateway's nativeOpen config pins the capability where canOpenNativePath platform detection would mislead. The privileged set is now read/copy/openDocument/remove. With files as the only composition editor, standing mounts grew stamp-keyed generations: ensureStanding compares the composition file's mtime+size and starts the next generation for later sessions, while every joined session keeps the generation it runs on. New keyless web lane (agent-preset-authoring, overlay pins nativeOpen: false so goldens render one branch on every platform) drives view/copy/reveal/delete end to end; the real-composition CLI e2e switches to copy semantics. --- ...08-08-per-preset-standing-mounts.i18n.yaml | 4 +- .../2026-08-08-per-preset-standing-mounts.md | 2 +- ...026-08-08-per-preset-standing-mounts.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 6 + .../2026-08-08-copy-only-preset-authoring.md | 30 + ...026-08-08-copy-only-preset-authoring.zh.md | 30 + apps/cli/tests/web-agent-presets.e2e.ts | 32 +- apps/web/tests/agent-preset-authoring.e2e.ts | 181 +++++ .../tests/agent-preset-authoring.overlay.yml | 12 + .../copy-dialog.expected.md | 14 + .../created.expected.md | 78 ++ .../section.expected.md | 60 ++ apps/web/tsconfig.json | 1 + docs/config-catalog.md | 8 + docs/cordis-catalog/services.md | 23 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 37 +- packages/client/connection/src/index.ts | 9 +- packages/client/connection/tests/fake-api.ts | 10 +- .../client/connection/tests/node-half.spec.ts | 7 +- packages/client/runtime/tests/fake-api.ts | 10 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 22 +- packages/client/ui-agent-preset/README.zh.md | 22 +- .../src/client/AgentPresetSection.module.css | 168 ++--- .../src/client/AgentPresetSection.tsx | 300 ++++---- .../ui-agent-preset/src/client/index.ts | 21 +- .../ui-agent-preset/src/client/locales.ts | 64 +- .../src/client/section-store.ts | 306 ++++---- .../src/client/settings-store.ts | 2 + .../ui-agent-preset/tests/apply.spec.ts | 65 +- .../tests/section-store.spec.ts | 682 +++++++++--------- .../ui-agent-preset/tests/section.spec.tsx | 273 +++---- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 62 +- .../apiproxy/src/api/agent-presets.schema.ts | 28 +- .../host/apiproxy/src/api/agent-presets.ts | 48 +- packages/host/apiproxy/src/api/rpc-map.ts | 3 +- packages/host/apiproxy/src/fetch/client.ts | 13 +- packages/host/apiproxy/src/fetch/handler.ts | 7 +- packages/host/apiproxy/src/index.ts | 10 + .../host/apiproxy/src/native-path-opener.ts | 19 + .../tests/api-proxy-agent-preset.spec.ts | 149 +++- .../apiproxy/tests/client-handler.spec.ts | 9 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 23 +- .../apiproxy/tests/native-path-opener.spec.ts | 34 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 20 +- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 20 +- packages/preset/agent-presets/README.zh.md | 20 +- .../preset/agent-presets/src/authoring.ts | 158 ++-- packages/preset/agent-presets/src/index.ts | 122 +++- .../agent-presets/tests/authoring.spec.ts | 203 ++++-- .../preset/agent-presets/tests/mount.spec.ts | 127 +++- tsconfig.host.json | 1 + 60 files changed, 2253 insertions(+), 1336 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md create mode 100644 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md create mode 100644 apps/web/tests/agent-preset-authoring.e2e.ts create mode 100644 apps/web/tests/agent-preset-authoring.overlay.yml create mode 100644 apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md create mode 100644 apps/web/tests/snapshots/agent-preset-authoring/created.expected.md create mode 100644 apps/web/tests/snapshots/agent-preset-authoring/section.expected.md diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index 6ec968a410..a8bd523cb6 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0 -2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3 +2026-08-08-per-preset-standing-mounts.md: 61aa737a4b7d6f15f160d3673a14cdc0c5df4a76 +2026-08-08-per-preset-standing-mounts.zh.md: 17de2cf3ec9ee9564cb5fde1fabb5014c389be39 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 19a53926e9..61aa737a4b 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -23,7 +23,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee ## Load-bearing details - **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. -- **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. +- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. - **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). - **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 856fbb742a..17de2cf3ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -23,7 +23,7 @@ Status: implemented ## Load-bearing details - **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 -- **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际),被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 +- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 - **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 - **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..94466cd530 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde +2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md new file mode 100644 index 0000000000..c16518b087 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -0,0 +1,30 @@ +# Agent Note: Copy-only preset authoring, and the way into a preset's files + +Status: implemented + +English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) + +## Problem + +The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against. + +## Decision + +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). + +## Consequences + +- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target. +- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart. +- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability. +- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards. + +## Load-bearing details + +- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. +- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`. +- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd. + +## Alternatives considered + +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md new file mode 100644 index 0000000000..dc2d7924cb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 + +Status: implemented + +[English](2026-08-08-copy-only-preset-authoring.md) | 中文 + +## Problem + +agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。 + +## Decision + +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 + +## Consequences + +- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。 +- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 +- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 +- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 + +## Load-bearing details + +- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError`,`errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 +- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`。 +- **e2e lane 钉死 `nativeOpen: false`**(`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。 + +## Alternatives considered + +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 28b35d67e1..04ad03099d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -376,32 +376,28 @@ describe('authoring a preset on the shipped composition', () => { }]) }) - it('refuses to overwrite or delete a shipped preset', async () => { - await expect(authorCtx.agentPresets.write('standard', '- id: x\n')).rejects.toThrow(/ships with the deployment/) + it('refuses to copy over or delete a shipped preset', async () => { + await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) }) it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { // The id becomes a directory name under the user root, so containment is // checked on the id rather than on the joined path afterwards. - await expect(authorCtx.agentPresets.write(id, '- id: x\n')).rejects.toThrow() + await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() }) - it('refuses text that is not a Cordis entry list', async () => { - await expect(authorCtx.agentPresets.write('bad-shape', 'tools: []\n')).rejects.toThrow() - await expect(authorCtx.agentPresets.resolve('bad-shape')).rejects.toThrow() - }) + it('copies a shipped preset a session then really composes from', async () => { + await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') - it('writes a preset a session then really composes from', async () => { - const copied = await authorCtx.agentPresets.read('minimal') - - await authorCtx.agentPresets.write('my-agent', copied) - - // Round-trips through the roster as a `user` row, and the composition the - // editor saved is one the mount actually accepts. + // Round-trips through the roster as a `user` row carrying the given name + // and the source's description, over the source's own composition text. const preset = await authorCtx.agentPresets.resolve('my-agent') + const source = await authorCtx.agentPresets.resolve('minimal') expect(preset.trust).toBe('user') - expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied) + expect(preset.name).toBe('我的模式') + expect(preset.description).toBe(source.description) + expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) // Owner-only, in an owner-only directory: a composition is executable // configuration on a machine that may have other users. expect((await stat(preset.path)).mode & 0o777).toBe(0o600) @@ -410,7 +406,7 @@ describe('authoring a preset on the shipped composition', () => { setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), }) try { - // The same tools the shipped `minimal` composes, from a file written + // The same tools the shipped `minimal` composes, from a directory copied // through the service into a root outside the installed harness. expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { @@ -418,8 +414,8 @@ describe('authoring a preset on the shipped composition', () => { } }) - it('deletes what it wrote', async () => { - await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n') + it('deletes what it copied', async () => { + await authorCtx.agentPresets.copy('minimal', 'doomed') await authorCtx.agentPresets.remove('doomed') diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts new file mode 100644 index 0000000000..2bf90b2105 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -0,0 +1,181 @@ +// Web e2e scenario: the agent-preset settings section as copy-only authoring. +// The browser never edits composition text — a shipped preset opens in a +// read-only viewer, the copy dialog collects an id and an optional display +// name, and the host copies the whole directory. The section's other job is +// getting the user TO the files: this lane pins `nativeOpen: false` (see the +// overlay), so the location affordance answers the preset directory as text — +// the deterministic branch a golden can hold on every platform. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Locator } from 'playwright' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url)) +const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') +const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: agent-preset authoring is a host-side copy', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let userRoot: string + + /** The settings dialog, opened on the Agent-presets section. */ + function settingsDialog(): Locator { + return page.getByRole('dialog', { name: '设置' }) + } + + /** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */ + function withPresetRoot(snapshot: string): string { + return snapshot.split(userRoot).join('{{presetRoot}}') + } + + beforeAll(async () => { + userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) + scaffold = await launchWebScaffold({ + extraOverlayPath: OVERLAY, + agentPresets: { + roots: [ + { path: SHIPPED_PRESETS, trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + default: 'standard', + }, + }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the roster with copy as the only way to create', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + // The intro carries the guidance a create button used to imply, and the + // shipped rows offer view/copy but never delete or a location — their + // install is overwritten by upgrades and is not the user's to manage. + expect(snapshot).toContain('复制「极简模式」') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).not.toContain('删除: 标准模式') + expect(snapshot).not.toContain('打开目录') + }, 60_000) + + it('views a shipped composition read-only instead of editing it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '查看: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await viewer.waitFor({ timeout: 10_000 }) + + // The real shipped composition, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only. + const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') + expect(await viewer.locator('pre').textContent()).toBe(shipped) + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one + // is last in the dialog. + await viewer.getByRole('button', { name: '关闭' }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + }, 60_000) + + it('copies 极简模式 whole under a new id and lands in its files', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + + const dialogSnapshot = await captureStableAria( + page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) + // Two fields and nothing else: the id is the directory name the host + // needs up front; description and composition live in the files. + expect(dialogSnapshot).toContain('标识符') + expect(dialogSnapshot).not.toContain('描述') + + await copyDialog.getByPlaceholder('my-agent').fill('my-agent') + await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + // The new row lands in the custom group, and — with no desktop opener — + // its directory is revealed as text right away: landing in the files is + // the completion of a copy, not a follow-up. + await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) + await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) + // The copy dialog is detached, so the settings dialog is the only one + // left (it names itself via aria-labelledby, which a CSS attribute + // selector cannot address). + const snapshot = withPresetRoot( + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('{{presetRoot}}/my-agent') + + // The host copied the whole directory and rewrote only the display + // metadata: the composition is byte-identical to the shipped source, the + // description rides along for the user to edit in place, and neither the + // source's name nor its roster order survives into the copy. + const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') + expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) + const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') + expect(metadata).toContain('name: 我的模式') + expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).not.toContain('order:') + }, 60_000) + + it('deletes the copy after confirmation and reclaims the roster', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '删除: 我的模式' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + + await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) + // Custom group gone with its only member; the shipped set stands. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) + }, 60_000) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml new file mode 100644 index 0000000000..6644752bc2 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -0,0 +1,12 @@ +# The authoring lane drives the location affordance. A real desktop open +# would pop a file manager on the machine running the tests and the +# capability itself is platform-detected (macOS yes, headless Linux CI no), +# so the gateway is pinned headless: `hasDocument` is false everywhere and +# `openDocument` answers the directory as text — the same branch on every +# host, and the one whose rendering a golden can hold. A patch replaces the +# row's complete config, so the shipped routing defaults ride along. +- id: api-gateway + config: + provider: deepseek-official + model: deepseek-v4-flash + nativeOpen: false diff --git a/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md new file mode 100644 index 0000000000..dc4045c5cc --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md @@ -0,0 +1,14 @@ +- dialog "复制预设 · 复制自 极简模式": + - heading "复制预设 · 复制自 极简模式" [level=2] + - button "关闭": + - img + - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - text: 标识符 + - textbox "标识符": + - /placeholder: my-agent + - text: 名称 + - textbox "名称": + - /placeholder: 选择器中显示的名字,缺省用标识符 + - alert: 请填写标识符。 + - button "取消" + - button "创建" [disabled] diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md new file mode 100644 index 0000000000..46ab77a945 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -0,0 +1,78 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - 'button "设为默认: 我的模式"': + - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: my-agent + - 'button "查看路径: 我的模式"': + - img + - text: 查看路径 + - 'button "复制: 我的模式"': + - img + - text: 复制 + - 'button "删除: 我的模式"': + - img + - text: 删除 + - paragraph: + - text: 预设文件: + - code: {{presetRoot}}/my-agent diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md new file mode 100644 index 0000000000..61df1d7b5a --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -0,0 +1,60 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c98d70ac97..dcb46df04e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -60,6 +60,7 @@ "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/agent-preset-selection.e2e.ts", + "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1d21a40a13..52a42f47c8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -649,6 +649,14 @@ export interface Config { model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cbcf894a1e..51239e4b6a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -91,18 +91,21 @@ async mount(agentCtx: Context, id?: string): Promise async read(id: string): Promise /** - * Create or replace a locally authored preset. + * Create a locally authored preset by copying an existing one whole. * - * The text is shape-checked before it lands, so a save cannot leave a file no - * session could load; it is NOT mounted, so a composition that parses but - * names a missing plugin still fails at the next session that selects it. - * @param id - the preset id, which becomes its directory name. - * @param content - the composition text. - * @param metadata - display name and description; clearing both removes the file. - * @throws when the id is unusable, the text is not an entry list, or the - * deployment configures no writable root. + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. */ -async write(id: string, content: string, metadata: PresetMetadata = {}): Promise +async copy(from: string, id: string, name?: string): Promise /** * Delete a locally authored preset. diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 508f2df6f7..31ff1dcbed 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: e597d047ab36cf34ac1bce041e162a42c01dc58a -README.zh.md: 9442633526266c981aa7b39942e9c0a9b34dd161 +README.md: da2cb781de6726596d1003ac9c2756b6113afc19 +README.zh.md: 87998d73ae5b768e4c3ded2967ab972fbc872c9f diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index e597d047ab..da2cb781de 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 9442633526..87998d73ae 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力;`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ea0df24b5b..6c2047691c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2464,6 +2464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { isDefault: id === fixtureDefaultPreset, })), authorable: true, + hasDocument: true, }), select: (request) => { fixtureDefaultPreset = request.payload.agentPreset @@ -2483,21 +2484,42 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentPreset, trust: preset.trust, content: preset.content, - writable: preset.trust === 'user', }) }, - write: (request) => { - const { agentPreset, content } = request.payload + copy: (request) => { + const { from, agentPreset } = request.payload + const source = fixturePresets.get(from) + if (source === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }) + } + if (fixturePresets.has(agentPreset)) { + return err(request, { + code: 'agent-preset-invalid', + message: `agent preset "${agentPreset}" already exists`, + details: { agentPreset, reason: 'already exists' }, + }) + } + fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) + return ok(request, { agentPreset }) + }, + // Native opens are deterministic no-op successes in this fixture, so the + // open-directory affordance renders and the path-text fallback stays a + // component-test concern. + openDocument: (request) => { + const { agentPreset } = request.payload const existing = fixturePresets.get(agentPreset) - if (existing?.trust === 'system') { + if (existing === undefined || existing.trust === 'system') { return err(request, { code: 'agent-preset-read-only', message: `agent preset "${agentPreset}" ships with the deployment`, details: { agentPreset, reason: 'it ships with the deployment' }, }) } - fixturePresets.set(agentPreset, { trust: 'user', content }) - return ok(request, { agentPreset }) + return ok(request, { opened: true as const }) }, remove: (request) => { const { agentPreset } = request.payload @@ -2835,7 +2857,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) case 'agentPreset.read': return this.api.agentPresets.read(request) - case 'agentPreset.write': return this.api.agentPresets.write(request) + case 'agentPreset.copy': return this.api.agentPresets.copy(request) + case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a99c942906..f865653b9f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -67,8 +67,10 @@ export const Config: z = z.object({ */ const PRIVILEGED_METHODS = new Set([ // A preset composition names the plugins a session runs, so reading one is - // reconnaissance and writing one is arbitrary capability — strictly more than - // the settings document beside it. + // reconnaissance; copy and remove rearrange what the deployment offers, and + // openDocument drives the host desktop — all more than the roster beside + // them. (Authoring is copy-only, so no method here accepts composition text + // or a path; the pin is about who may manage the roster at all.) // // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a // preset looks like escalation — one of them mounts the toolset that edits the @@ -79,7 +81,8 @@ const PRIVILEGED_METHODS = new Set([ // any caller that may start a session at all can already run commands as this // process. Pinning the switch would be a fence beside an open gate. 'agentPreset.read', - 'agentPreset.write', + 'agentPreset.copy', + 'agentPreset.openDocument', 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 14b6010b68..8cc5d67136 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -169,15 +169,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), read: (payload: { agentPreset: string }) => this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', }))), - write: (payload: { agentPreset: string }) => - this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), remove: (payload: { agentPreset: string }) => this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 8bda2904b8..e3a4d6cb26 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -160,8 +160,9 @@ describe('connection node half', () => { 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', // A composition names the plugins a session runs: reading one is - // reconnaissance and writing one is arbitrary capability. - 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -455,7 +456,7 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', - 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b58984bf75..2e92f885be 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -204,15 +204,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), read: (payload: { agentPreset: string }) => this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', }))), - write: (payload: { agentPreset: string }) => - this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), remove: (payload: { agentPreset: string }) => this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 0e6ce1245b..00d6c41704 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b -README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78 +README.md: c43fbff7846153911d0dc5bd85935470b35dcbff +README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c9aa370ebb..c43fbff784 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves. +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. ## Why it is a new-session preference @@ -30,21 +30,23 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/ ## The management section -A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time. +A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions. -A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from; **New preset** starts blank, since copying is already offered on the row being copied and a composition nobody named is text the author has to recognise as unwanted before deleting it. +The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing. -An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it. +A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: to start from the smallest skeleton, duplicate 极简模式 (31 lines against standard's 233). -Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. +The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure. + +Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). -`agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it. +`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. ## When the surfaces are absent -A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails. +A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails. ## Model Experience @@ -56,6 +58,6 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name. -- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation. -- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it. +- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. +- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself. +- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 707745c547..1fca73a5ef 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身。 +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。 ## 为什么它是"新建会话"的偏好设置 @@ -30,21 +30,23 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 管理分区 -第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 +第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。 -随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉。 +浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。 -id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。 +随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)。 -删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 +对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。 + +删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 -`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。 +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 ## 何时不显示这些表层 -未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 +未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。 ## Model Experience @@ -56,6 +58,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名。 -- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。 -- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。 +- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。 +- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 +- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 74f37266f8..8851ae4efe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -6,31 +6,6 @@ color: var(--dsw-alias-label-primary); } -/* Editing form: take the settings column's whole height so the composition - gets the space the panel has, instead of a fixed-row box with an empty band - under it. The list keeps the default flow — a card grid pinned to the top - reads as a list, while a stretched one would drift its rows apart. */ -.sectionFill { - height: 100%; -} - -.sectionFill .editor { - flex: 1; - min-height: 0; -} - -.sectionFill .codeField { - flex: 1; - min-height: 0; -} - -.sectionFill .codeField .code { - flex: 1; - min-height: 160px; - /* The drag handle would fight the flex height it is nested in. */ - resize: none; -} - .title { margin: 0; font-size: 18px; @@ -43,17 +18,6 @@ color: var(--dsw-alias-label-tertiary); } -.notice { - margin: 0; - font-size: 12px; - color: var(--dsw-alias-state-warn-label); -} - -.hint { - font-size: 12px; - color: var(--dsw-alias-label-tertiary); -} - /* Cards, not rows: a preset is a thing you pick, and the description is the part that tells them apart — a row would bury it beside the actions. */ .group { @@ -199,6 +163,11 @@ align-items: center; } +.iconButton:disabled { + opacity: 0.4; + cursor: default; +} + .iconButton:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); @@ -237,6 +206,28 @@ color: var(--dsw-alias-state-error-primary); } +/* Where the host has no desktop opener, the row answers with the directory + itself — text to copy, not a control that would spawn into nothing. */ +.revealedPath { + margin: 0; + padding: 6px 16px 10px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); + display: flex; + gap: 6px; + align-items: baseline; +} + +.revealedPath code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--dsw-alias-label-secondary); + user-select: all; + overflow-wrap: anywhere; +} + +.revealedPathLabel { + white-space: nowrap; +} .secondaryButton { border: none; @@ -254,18 +245,11 @@ background: var(--dsw-alias-bg-layer-1); } -.secondaryButton:disabled, -.addButton:disabled { +.secondaryButton:disabled { opacity: 0.5; cursor: default; } -.editor { - display: flex; - flex-direction: column; - gap: 12px; -} - .field { display: flex; flex-direction: column; @@ -278,8 +262,7 @@ color: var(--dsw-alias-label-secondary); } -.input, -.code { +.input { box-sizing: border-box; padding: 9px 12px; border: 1px solid var(--dsw-alias-border-l2); @@ -290,18 +273,7 @@ color: var(--dsw-alias-label-primary); } -.code { - font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - line-height: 1.5; - resize: vertical; - white-space: pre; - overflow-wrap: normal; - overflow-x: auto; - tab-size: 2; -} - -.input:focus, -.code:focus { +.input:focus { outline: none; border-color: var(--dsw-alias-brand-primary); } @@ -310,67 +282,35 @@ color: var(--dsw-alias-label-dimmed); } -/* A shipped composition is drawn a rung up, and it is the one most likely to - overflow, so its scroll thumb rebinds to that rung. */ -.code[readonly] { - color: var(--dsw-alias-label-secondary); +.dialog { + width: min(560px, 100%); +} + +.dialogFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* A shipped composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -.editorActions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.editorBar { - display: flex; - align-items: baseline; - gap: 12px; - padding-bottom: 4px; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.backButton { - appearance: none; - border: 0; - background: none; - padding: 0; - font: inherit; - font-size: 13px; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; -} - -.backButton:hover { - color: var(--dsw-alias-label-primary); -} - -.backButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; - border-radius: 4px; -} - -.editorTitle { - font-size: 14px; - font-weight: 600; -} - -.addButton { - align-self: flex-start; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - font-size: 13px; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - cursor: pointer; -} - .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index dbd6ff05d0..8e4557dbf3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,21 +1,23 @@ /** - * Agent-presets settings section: the roster as rows, and one composition - * open in a YAML editor at a time. + * Agent-presets settings section: the roster as cards, a copy dialog as the + * only way a preset is created, and a read-only viewer over the shipped + * compositions. * - * A shipped preset opens read-only — it is the known-good composition a local - * one is written against — so authoring starts by duplicating one. Deleting a - * preset leaves running sessions alone: a composition is mounted once at - * session creation and nothing re-reads the file. + * The browser edits no composition text — a shipped preset opens read-only to + * be READ (it is the known-good composition a copy starts from), and a custom + * preset is edited in its own files, which is what the location action leads + * to. Deleting a preset leaves running sessions alone: a composition is + * mounted once at session creation and nothing re-reads the file. */ import { useEffect } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconEditOutline16, IconTrashOutline16, Modal, + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpen16, IconTrashOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts' +import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import type { AgentPresetSettingsKey } from './locales.ts' import css from './AgentPresetSection.module.css' @@ -27,22 +29,22 @@ export interface AgentPresetSectionInjected { } /** Read the roster; called once when the section first renders. */ load: () => Promise - /** Open one preset's composition in the editor. */ - open: (id: string) => Promise - /** Open a copy of one preset — or of the default — as a new preset. */ - createFrom: (from?: string) => Promise - /** Close the editor, discarding the draft. */ - close: () => void - /** Name the preset a new draft saves to. */ - setId: (id: string) => void - /** Replace the draft's composition text. */ - setContent: (content: string) => void - /** Rename the draft. */ - setName: (name: string) => void - /** Replace the draft's description. */ - setDescription: (description: string) => void - /** Save the open draft. */ - save: () => Promise + /** Open one shipped preset's composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void + /** Open the copy dialog over one preset. */ + beginCopy: (from: string) => void + /** Close the copy dialog, discarding the draft. */ + cancelCopy: () => void + /** Name the preset the copy creates. */ + setCopyId: (id: string) => void + /** Name the copy's display name. */ + setCopyName: (name: string) => void + /** Submit the copy. */ + confirmCopy: () => Promise + /** Open one preset's directory, or reveal its path where there is no desktop. */ + openLocation: (id: string) => Promise /** Ask for delete confirmation, or dismiss it with null. */ confirmDelete: (id: string | null) => void /** Delete the preset awaiting confirmation. */ @@ -57,93 +59,73 @@ export type AgentPresetSectionProps = & PropsLocale<'settings.agentPreset'> & InjectFace -/** Editor sub-view props: the draft plus the actions that mutate it. */ -interface EditorProps { - draft: PresetDraft - blocker: ReturnType +/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */ +interface CopyDialogProps { + state: AgentPresetSectionState t: (key: AgentPresetSettingsKey) => string actions: Pick + 'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'> } -function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode { - const message = draft.error ?? (blocker === undefined ? null : t(blocker)) +function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { + const draft = state.copy + const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) + const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) return ( -
- {draft.creating - ? ( - - ) - : null} - {draft.writable - ? ( - <> + { actions.cancelCopy() }} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + closeLabel={t('close')} + description={t('copyIntro')} + className={css.dialog as string} + footer={( + <> + + + + )} + > + {draft === null + ? null + : ( +
+ - - - ) - : null} - {draft.writable ? null :

{t('readOnlyNotice')}

} -