Merge remote-tracking branch 'origin/master' into worktree/unify-workspace-add
This commit is contained in:
@@ -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/bug-fix/2026-07-30-tui-adapter-registration-race.md
|
||||
2026-07-30-tui-adapter-registration-race.md: fd08e7b6130bc8f7e3cd5287a9970f5fb47244a8
|
||||
2026-07-30-tui-adapter-registration-race.zh.md: 0c6bba4bbc8c3303d9c471c3164faa816438b333
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: TUI model-context resolution defers on the adapter-registration race
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-tui-adapter-registration-race.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Cordis activates plugins by service availability, not configuration order, so the TUI (whose `inject` requires only the `llm` service) can mount before a configured adapter plugin such as `dsh-llm-pi-ai` finishes registering its provider routes. The TUI's model controller resolves the selected model's context window immediately on mount; when the agent's route pointed at a not-yet-registered provider, `resolveModelInfo` rejected with `NO_ADAPTER` and every fresh session printed `Could not resolve model context: no adapter registered for provider "…"` — a spurious error for a fully working configuration (the adapter registered milliseconds later, and chatting worked).
|
||||
|
||||
## Decision
|
||||
|
||||
The TUI model controller treats a `NO_ADAPTER` rejection of its context-window resolution as a transient state rather than an error: it parks the resolution silently and re-resolves on the next `llm/adapters-updated` commit — the payload-free registry notification `LlmService` already fires at every route commit point. A commit that still lacks the route parks the wait again, so unrelated topology changes stay silent. Any target change re-enters the resolution and clears the pending wait, so the deferred state can never go stale against the current selection; every other resolution error still prints the notice.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Have the TUI wait for boot to settle before resolving.** The TUI has no Loader dependency (tests and embedders run without one) and "settled" is not observable from inside a plugin; adding a Loader coupling for one cosmetic resolution inverts the dependency direction.
|
||||
|
||||
**Poll or retry with a timer.** A timer guesses at activation latency, still mis-prints on a slow adapter, and adds a tunable with no owner. The registry already announces every commit through `llm/adapters-updated`; subscribing is precise and free.
|
||||
|
||||
**Order the config so adapters load first.** Row order carries no load semantics in the Loader (activation is service-driven by design), so this cannot be expressed in configuration.
|
||||
|
||||
**Suppress NO_ADAPTER errors entirely.** A permanently missing adapter (typo in the provider name) would then never surface in the context-window path. Deferring keeps the signal: a wrong provider name still shows `model unset`-like behavior in the selector and fails loudly at dispatch, while the startup race resolves itself.
|
||||
|
||||
**Resolve the context window per submitted message instead of at mount.** The send path already resolves per step (`prepareCall()`), and the indicator is displayed continuously, not only when sending; per-submit display resolution would leave the indicator blank until the first message and re-run adapter I/O for a value that only changes on route changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
A genuinely misconfigured provider no longer prints the context-resolution error at startup — it surfaces at first dispatch instead, which is where the failure is actionable. The controller subscribes to every `llm/adapters-updated` commit but acts only while a wait is parked; the listener's disposer is released by the channel's `detachListeners()` through the controller's `detach()`, symmetric with the sibling channel listeners. Covered by three TUI tests: the deferred resolution stays silent through an unrelated commit and completes when the route's commit arrives, a target change drops the stale wait, and after channel detach a registry commit no longer re-enters resolution.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: TUI 模型上下文解析在适配器注册竞争时延后重试
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-tui-adapter-registration-race.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Cordis 按服务可用性而非配置顺序激活插件,因此 TUI(其 `inject` 只要求 `llm` 服务)可能在 `dsh-llm-pi-ai` 这类已配置的适配器插件完成提供方路由注册之前就挂载。TUI 的模型控制器在挂载时立即解析所选模型的上下文窗口;当 agent 的路由指向尚未注册的提供方时,`resolveModelInfo` 以 `NO_ADAPTER` 拒绝,于是每个新会话都会打印 `Could not resolve model context: no adapter registered for provider "…"` —— 对一份完全正常的配置报出的虚假错误(适配器几毫秒后就完成注册,对话也一切正常)。
|
||||
|
||||
## Decision
|
||||
|
||||
TUI 模型控制器把上下文窗口解析中的 `NO_ADAPTER` 拒绝视为瞬态状态而非错误:静默搁置这次解析,并在下一次 `llm/adapters-updated` 提交时重新解析——这是 `LlmService` 本就在每个路由提交点发出的无载荷注册表通知。若某次提交仍缺少该路由,等待会被再次搁置,因此无关的拓扑变化保持沉默。任何目标变更都会重新进入解析并清除挂起的等待,因此延后状态绝不会相对当前选择变陈旧;其他所有解析错误仍照常打印通知。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 TUI 等启动结算后再解析。** TUI 不依赖 Loader(测试和嵌入方在没有 Loader 的环境下运行),而且"已结算"在插件内部不可观测;为一次外观性的解析引入 Loader 耦合会颠倒依赖方向。
|
||||
|
||||
**用定时器轮询或重试。** 定时器只能猜测激活延迟,遇到慢适配器仍会误报,还会引入一个没有归属者的可调参数。注册表本就通过 `llm/adapters-updated` 公告每次提交;订阅它既精确又零成本。
|
||||
|
||||
**调整配置顺序让适配器先加载。** Loader 中行顺序不承载加载语义(激活按设计由服务驱动),因此这无法用配置表达。
|
||||
|
||||
**彻底压制 NO_ADAPTER 错误。** 那样的话,永久缺失的适配器(提供方名字拼错)在上下文窗口路径上就永远不会暴露。延后重试保留了信号:错误的提供方名字仍会在选择器中表现出类似 `model unset` 的行为,并在分派时大声失败,而启动竞争则自行化解。
|
||||
|
||||
**改为在每次提交消息时解析上下文窗口,而不是在挂载时。** 发送路径本就按步解析(`prepareCall()`),且指示器是持续显示的,不只在发送时;按提交解析显示值会让指示器在首条消息之前一直空白,并为一个仅在路由变化时才变的值反复执行适配器 I/O。
|
||||
|
||||
## Consequences
|
||||
|
||||
真正配置错误的提供方不再在启动时打印上下文解析错误——它改在首次分派时暴露,那才是该失败可以被处理的地方。控制器订阅每次 `llm/adapters-updated` 提交,但只在有等待被搁置时才动作;监听器的 disposer 经由控制器的 `detach()` 在频道的 `detachListeners()` 中释放,与同级频道监听器保持对称。由三个 TUI 测试覆盖:延后的解析在无关提交中保持沉默、在该路由的提交到来时完成;目标变更丢弃陈旧等待;频道 detach 之后注册表提交不再重新进入解析。
|
||||
@@ -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/bug-fix/2026-07-31-english-compaction-checkpoints.md
|
||||
2026-07-31-english-compaction-checkpoints.md: dc95ed187a6ba5b86800f06ebefb2776995f9421
|
||||
2026-07-31-english-compaction-checkpoints.zh.md: 7cd4179430ed775f3807af5a02e81838e495c81e
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agent Note: Compaction checkpoints use an English engineering register
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-english-compaction-checkpoints.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A compaction checkpoint becomes part of the next model request's durable prefix. When a multilingual conversation leads the compactor to preserve its narrative material in the conversation language, the checkpoint can introduce a large amount of a language that is absent from the code, tool output, and existing reasoning prefix. That language then persists across later compaction cycles and can influence the conversation model's reasoning register.
|
||||
|
||||
## Decision
|
||||
|
||||
`COMPACTION_INSTRUCTION` requires an English-language internal engineering checkpoint. It asks the model to translate narrative source material as needed while preserving exact literals, including paths, commands, errors, identifiers, signatures, and quoted wording when exactness matters. The checkpoint's headings and terse engineering bullets remain the existing structured format.
|
||||
|
||||
The requirement is integrated into the first sentence of the trailing compaction instruction. The replayed system prompt, tools, and conversation history remain byte-identical to the routed request, so the change retains the prefix-cache reuse owned by the [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Leave checkpoint language to the replayed conversation** — rejected: the checkpoint is a durable prompt prefix, so preserving a transient conversational register can amplify it across later compactions.
|
||||
- **Constrain the conversation model's language** — rejected: the policy is for an internal checkpoint, not the user's visible conversation, and a conversation-wide rule would unnecessarily change normal interaction.
|
||||
- **Require ASCII-only output** — rejected: ASCII is a character-set constraint rather than an engineering-register constraint and would unnecessarily distort legitimate literals and technical material.
|
||||
- **Append a separate final English-only sentence** — rejected: stating the requirement in the instruction's opening output contract is shorter and ties it directly to the checkpoint being requested.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New checkpoints normalize narrative context into English while retaining the exact strings that future tool use and code work depend on.
|
||||
- Existing checkpoint structure, compaction routing, and cache alignment are unchanged; only the final user instruction is different.
|
||||
- The direct summarization call remains outside transcript snapshots because it emits no `assistant/chunk` events. The real-loop regression instead asserts the exact final instruction received by the summarization request.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Agent Note: 压缩检查点使用英语工程文体
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-english-compaction-checkpoints.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
压缩(compaction)检查点会成为下一次模型请求中持久存在的前缀。当多语言对话使压缩器以对话语言保留叙述性材料时,检查点可能引入大量代码、工具输出和既有推理(reasoning)前缀中均未出现的语言内容。该语言随后会在后续压缩周期中持续存在,并影响对话模型的推理文体。
|
||||
|
||||
## 决策
|
||||
|
||||
`COMPACTION_INSTRUCTION` 要求生成英语的内部工程检查点。它要求模型在必要时翻译叙述性源材料,同时保留精确的字面量;这包括路径、命令、错误、标识符、签名,以及精确性重要时的引用措辞。检查点的标题及简洁的工程项目符号仍沿用既有的结构化格式。
|
||||
|
||||
这项要求被整合到尾部压缩指令的第一句话中。回放的系统提示词、工具和对话历史与已路由请求保持字节级一致,因此该变更保留 [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md) 所确立的前缀缓存复用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **让回放的对话决定检查点语言**——不采纳:检查点是持久的提示词前缀,保留短暂的对话文体可能在后续压缩中放大这种影响。
|
||||
- **约束对话模型的语言**——不采纳:该策略针对内部检查点,而不是用户可见的对话;对整个对话施加规则会不必要地改变正常交互。
|
||||
- **要求仅输出 ASCII**——不采纳:ASCII 是字符集约束,而非工程文体约束,并会不必要地扭曲合法的字面量和技术材料。
|
||||
- **在末尾追加一句独立的仅限英语要求**——不采纳:在指令开头的输出契约中说明该要求更简洁,也直接将其与所请求的检查点绑定。
|
||||
|
||||
## 后果
|
||||
|
||||
- 新检查点会将叙述性上下文规范化为英语,同时保留未来工具使用和代码工作所依赖的精确字符串。
|
||||
- 既有检查点结构、压缩路由和缓存对齐保持不变;只有最后一条 user 指令不同。
|
||||
- 直接摘要调用仍不纳入 transcript(文本记录)快照,因为它不会发出 `assistant/chunk` 事件。真实循环回归改为断言摘要请求收到的精确最终指令。
|
||||
@@ -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/feature/2026-07-30-deepseek-onboarding-credential-setup.md
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: 571b81a1a2e6f392f2553070048964d49941aae9
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 744c30814f84d063f196ce20ba48fb993d0b7713
|
||||
2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4
|
||||
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247
|
||||
|
||||
@@ -12,11 +12,11 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
**One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only.
|
||||
|
||||
**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract.
|
||||
**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md).
|
||||
|
||||
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret.
|
||||
**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin.
|
||||
|
||||
**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability suppresses the modal because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later dismisses a missing-credential overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload.
|
||||
**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma
|
||||
|
||||
## Consequences
|
||||
|
||||
The first-run flow leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds.
|
||||
|
||||
@@ -10,13 +10,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 所有、设置路径为空的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同一提供方 ID 下的存活路由若没有匹配的可配置提供方声明,首次使用引导会将其视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
|
||||
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。
|
||||
|
||||
**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。
|
||||
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
|
||||
|
||||
**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。
|
||||
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
|
||||
|
||||
**不可用状态不会拦截产品交互。**可配置提供方条目缺失、路由未激活、初始联接失败、部署只读、设置能力无法解析或凭据能力无法解析时均不显示模态框,因为首次使用引导的操作无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会在当前已挂载界面中关闭凭据缺失浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。
|
||||
**不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -30,4 +30,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
首次使用流程无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放链路还固化了同一提供方 ID 下的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。
|
||||
|
||||
@@ -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/feature/2026-07-30-search-render-card.md
|
||||
2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b
|
||||
2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e
|
||||
@@ -0,0 +1,61 @@
|
||||
# Agent Note: Search render intent — grep and glob emit a structured search card
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-search-render-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`grep` and `glob` return structured canonical values — `grep` a flat `{ matches: [{ path, lineNumber, line }] }`, `glob` a `{ paths: string[] }` — but every UI only ever saw their model-facing render text: `grep` groups its matches under file headers with `Line N:` rows, `glob` prints a newline-joined path list, and both append a spill footer when the inline cap (`grepMaxMatches`, default 250; `globMaxResults`, default 100) drops later results to a spill file. A web frontend that wants to render a search result as an expandable per-file group of matches, or as a selectable path list, had to re-parse that text. Both tools already declared a call-time [render intent](../architecture/2026-07-02-tool-render-intent-union.md) (`GenericCallView`, `kind: 'search'`) but no result-time view, so the completed call fell back to the generic card that renders the raw text.
|
||||
|
||||
The structured canonical value does not cross the wire: only the model-facing render text and, when a tool declares `output.presentationMeta`, a JSON metadata payload reach the client, threaded through the `tool/result` event ([canonical-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). A result-time view carrying structured data therefore has to project that data into `presentationMeta` and read it back in `presentResult` — the same path `write`/`edit` use for their diff cards.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/core/tools/src/presentation.ts` adds `card: 'search'` to the `ToolResultView` union as `SearchResultView`, a `shape`-discriminated view that expresses both tools' shapes: `SearchMatchesResultView` (`shape: 'matches'`) carries `grep`'s matches grouped by file as `files: { path, matches: { lineNumber, line }[] }[]`, and `SearchPathsResultView` (`shape: 'paths'`) carries `glob`'s flat `paths: string[]`. Both carry `truncated: boolean` and `total: number`.
|
||||
|
||||
The discriminant is `shape`, not `kind`, deliberately: the same presentation module already gives `GenericCallView` a `kind: ToolCallKind` field whose values include `'search'` (the icon category). A bridge holding a `ToolCallView | ToolResultView` would see two `kind` fields with two meanings; `shape` for the result variant keeps the two apart.
|
||||
|
||||
One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
|
||||
|
||||
The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content.
|
||||
|
||||
The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
|
||||
|
||||
`packages/fs/tool-fs-search/src/presentation.ts` owns the projection and the narrowing. `grepSearchMeta`/`globSearchMeta` project the canonical value into a `SearchMeta` payload each tool declares as `output.presentationMeta`; `presentGrepResult`/`presentGlobResult` read `result.meta` back through `searchViewFromMeta`. They consume the SAME retained result the model-facing render consumes — `retainGrepMatches`/`retainGlobPaths` in `search-core.ts` run the inline cap and per-line preview budget ONCE, and both the render and the projection take that outcome — so text and card never disagree about which results survived, and there is no second retention pass. `total` is every result the search found (before capping); `truncated` is set when the cap dropped results. This is the truncation-honesty point: the model saw a capped inline result plus a spill footer, so the card must not present the retained page as the complete result — a UI reads `truncated`/`total` to show a capped indicator rather than claiming completeness the model never had.
|
||||
|
||||
**The meta has its own byte budget.** The inline cap bounds the item COUNT, but the retained matches of a broad search (hundreds of long lines) can still serialize to hundreds of kilobytes, and `meta` is persisted with the session log and re-sent on every request. A deployment's final output budget (`dsh-spill-policy`, `maxInlineBytes`) only shrinks a result's `content` — `PostToolDecision` has no `meta` channel — so the projection owns keeping `meta` bounded. `capMetaBytes` drops trailing file groups / paths until the serialized meta fits `searchMetaMaxBytes` (config, default 64 KiB) and marks the result `truncated`. A single item too large to fit on its own is kept: the invariant is a bounded payload wherever droppable, never an empty card that hides a real result.
|
||||
|
||||
`searchViewFromMeta` narrows the opaque `meta` defensively and returns `undefined` on any malformed or absent payload, so a presenter run on an older or hand-edited replayed log falls back to the generic card instead of throwing. It DOES accept a zero-result payload (`files: []` / `paths: []`) as a valid empty card — this is a deliberate departure from the mirrored `diffsFromMeta`, which rejects empty `diffs`, because a zero-match grep is a legitimate result a UI shows as "no matches", not an absent projection. `presentResult` returns `undefined` for a failed result, for absent meta (a nested `run_code` dispatch computes no `presentationMeta`), and for the other tool's meta shape (each presenter narrows to its own `shape`).
|
||||
|
||||
The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
|
||||
|
||||
The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly, and a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A single flat `SearchResultView` interface with optional `files?` and `paths?`.** Rejected: it makes both shape-specific fields optional on every value and lets a malformed view carry both or neither. The `shape` discriminant keeps each variant's fields required and lets a consumer switch exhaustively.
|
||||
|
||||
**Reuse `kind` as the shape discriminant.** Rejected: `kind` already means `ToolCallKind` (the icon category, whose values include `'search'`) on the call view in the same module. A second `kind` with a different meaning on the result view collides for any bridge holding both.
|
||||
|
||||
**Attach the model-facing text as the view's `content`.** Rejected: a no-op for every current consumer and a second serialization of the whole search text into the persisted view. The view is the structured shape; text fallback reads the raw result content.
|
||||
|
||||
**A meta channel on `PostToolDecision` so `dsh-spill-policy` bounds `meta` like it bounds `content`.** Rejected for this PR: it changes the core tool decision contract and the spill-policy plugin for one tool's payload. The projection bounding its own `meta` at a config byte cap is self-contained and keeps the seam unchanged.
|
||||
|
||||
**A call-time `SearchCallView` mirroring the terminal card's both-sides symmetry.** Rejected: a search call has no matches or paths before `execute`, so the view would carry only the title the `GenericCallView` already carries.
|
||||
|
||||
## Consequences
|
||||
|
||||
`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
|
||||
|
||||
A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/fs/tool-fs-search/tests/presentation.spec.ts` pins the pure layer: `groupMatchesByFile`'s first-seen file order; `grepSearchMeta`/`globSearchMeta` projection over a shared retention outcome with `total` reporting the pre-cap count and `truncated` carried through; the per-line preview budget the retention pass applied; the serialized-meta byte cap dropping trailing groups/paths while keeping a single oversized item; and `searchViewFromMeta`'s narrowing of both good shapes, the zero-result empty card, and every malformed case (non-object/array meta, missing or mistyped `truncated`/`total`, unknown `shape`, malformed `files` entries, non-string `paths`). `packages/fs/tool-fs-search/tests/tools.spec.ts` pins the wiring through the real tool registry: a capped `grep`/`glob` execute produces the `SearchMeta` on `result.meta` and `presentResult` builds the search view (no `content`), a nested `run_code` dispatch computes no meta so `presentResult` falls back, and a failed or cross-shape or malformed result falls back to the generic card. Per-file 100% coverage holds over the search package `src`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `search` result tag.
|
||||
- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — the value/render/`presentationMeta` split this projection rides; the structured value stays execution-local, the card rides `meta`.
|
||||
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors on the backend: a tool projects its result into `presentationMeta` and a `presentResult` view; the search card's web consumer is the analogous follow-up.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Agent Note:搜索渲染意图 —— grep 与 glob 产出结构化搜索卡片
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-search-render-card.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`grep` 与 `glob` 返回结构化的 canonical 值 —— `grep` 是扁平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }` —— 但每个 UI 只见过它们面向模型的渲染文本:`grep` 把匹配按文件头分组、每行 `Line N:`,`glob` 打印换行连接的路径列表,两者在内联上限(`grepMaxMatches`,默认 250;`globMaxResults`,默认 100)把后续结果落到 spill 文件时都追加一个 spill 脚注。想把搜索结果渲染成可展开的按文件匹配组、或可选择的路径列表的 web 前端,只能去重新解析那段文本。两个工具都已声明调用时的[渲染意图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果时视图,所以已完成的调用回退到渲染原始文本的 generic 卡片。
|
||||
|
||||
结构化 canonical 值不跨线传输:只有面向模型的渲染文本、以及当工具声明了 `output.presentationMeta` 时的一份 JSON 元数据,会经 `tool/result` 事件到达客户端([canonical-output 契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果时视图必须把数据投影进 `presentationMeta`,再在 `presentResult` 里读回 —— 与 `write`/`edit` 的 diff 卡片走同一条路。
|
||||
|
||||
## 决定
|
||||
|
||||
`packages/core/tools/src/presentation.ts` 把 `card: 'search'` 作为 `SearchResultView` 加入 `ToolResultView` 联合,这是一个以 `shape` 判别的视图,表达两个工具的形状:`SearchMatchesResultView`(`shape: 'matches'`)以 `files: { path, matches: { lineNumber, line }[] }[]` 承载 `grep` 按文件分组的匹配,`SearchPathsResultView`(`shape: 'paths'`)承载 `glob` 的扁平 `paths: string[]`。两者都带 `truncated: boolean` 与 `total: number`。
|
||||
|
||||
判别子是 `shape` 而非 `kind`,是刻意为之:同一个 presentation 模块已经给 `GenericCallView` 一个 `kind: ToolCallKind` 字段,其取值恰好包含 `'search'`(图标类别)。持有 `ToolCallView | ToolResultView` 的桥接层会看到两个含义不同的 `kind` 字段;结果变体用 `shape` 把两者分开。
|
||||
|
||||
用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选(matches 视图总有 `files`,paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。
|
||||
|
||||
该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;那对每个消费方都是 no-op(TUI 本就回退到 `result.content`,web 回退读原始 `tool/result` 内容),却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 `tool/result` 内容。
|
||||
|
||||
卡片标签只在结果时存在。搜索调用保持为 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 `execute` 之后才存在。
|
||||
|
||||
`packages/fs/tool-fs-search/src/presentation.ts` 拥有投影与收窄。`grepSearchMeta`/`globSearchMeta` 把 canonical 值投影为每个工具声明为 `output.presentationMeta` 的 `SearchMeta` 载荷;`presentGrepResult`/`presentGlobResult` 经 `searchViewFromMeta` 把 `result.meta` 读回。它们消费与面向模型渲染相同的已保留结果 —— `search-core.ts` 里的 `retainGrepMatches`/`retainGlobPaths` 只跑一次内联上限与每行预览预算,render 与投影都取这份产出 —— 所以文本与卡片对哪些结果幸存永不分歧,也没有第二次保留计算。`total` 是搜索找到的全部结果(截断前);`truncated` 在上限丢弃了结果时置位。这是截断诚实点:模型看到的是被截断的内联结果加一个 spill 脚注,所以卡片不能把保留页当作完整结果 —— UI 读 `truncated`/`total` 显示截断指示,而非宣称模型从未有过的完整性。
|
||||
|
||||
**meta 有自己的字节预算。** 内联上限约束的是条目数,但一次宽泛搜索保留下来的匹配(数百条长行)仍可序列化到数百 KB,而 `meta` 会随会话日志持久化并在每次请求时重发。部署的最终输出预算(`dsh-spill-policy` 的 `maxInlineBytes`)只缩减结果的 `content` —— `PostToolDecision` 没有 `meta` 通道 —— 所以投影自己负责把 `meta` 约束住。`capMetaBytes` 丢弃末尾的文件组/路径,直到序列化 meta 装进 `searchMetaMaxBytes`(配置,默认 64 KiB),并把结果标记 `truncated`。单个大到自身都装不下的条目会被保留:不变量是可丢弃处一律有界,绝不产出隐藏了真实结果的空卡片。
|
||||
|
||||
`searchViewFromMeta` 防御性地收窄不透明的 `meta`,对任何畸形或缺失载荷返回 `undefined`,使在较旧或手工编辑的回放日志上运行的 presenter 回退到 generic 卡片而非抛错。它确实接受零结果载荷(`files: []` / `paths: []`)为合法的空卡片 —— 这是与被镜像的 `diffsFromMeta` 的刻意偏离(后者拒绝空 `diffs`),因为零匹配的 grep 是 UI 展示为「no matches」的合法结果,而非缺失的投影。`presentResult` 对失败结果、对缺失 meta(嵌套 `run_code` 分发不计算 `presentationMeta`)、以及对另一工具的 meta 形状(每个 presenter 收窄到自己的 `shape`)返回 `undefined`。
|
||||
|
||||
`SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。
|
||||
|
||||
TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,`search` 视图落入同一个变暗的 generic body,从 `this.result?.content` 读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 `files`/`paths` 形状的 web 前端是另一个后续 PR;本 PR 是后端契约及其两个生产者。
|
||||
|
||||
## 考虑过的备选
|
||||
|
||||
**一个扁平的 `SearchResultView` 接口,带可选 `files?` 与 `paths?`。** 否决:它让两个形状相关字段在每个值上都可选,并允许畸形视图同时带两者或都不带。`shape` 判别式让每个变体的字段保持必需,并让消费方穷尽分支。
|
||||
|
||||
**复用 `kind` 作形状判别子。** 否决:同一模块里调用视图上的 `kind` 已经表示 `ToolCallKind`(图标类别,取值含 `'search'`)。结果视图上再有一个含义不同的 `kind`,对任何同时持有两者的桥接层都会冲突。
|
||||
|
||||
**把面向模型的文本作为视图的 `content` 附上。** 否决:对每个当前消费方是 no-op,且把整段搜索文本第二次序列化进持久化视图。视图是结构化形状;文本回退读原始结果内容。
|
||||
|
||||
**在 `PostToolDecision` 上加 meta 通道,让 `dsh-spill-policy` 像约束 `content` 那样约束 `meta`。** 本 PR 否决:它为一个工具的载荷改动核心工具决策契约与 spill-policy 插件。投影在配置字节上限处约束自己的 `meta` 是自包含的,且保持 seam 不变。
|
||||
|
||||
**镜像 terminal 卡片双侧对称的调用时 `SearchCallView`。** 否决:搜索调用在 `execute` 前没有匹配或路径,视图只会携带 `GenericCallView` 已有的标题。
|
||||
|
||||
## 后果
|
||||
|
||||
`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。
|
||||
|
||||
无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化,TUI 也逐字节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/fs/tool-fs-search/tests/presentation.spec.ts` 钉住纯层:`groupMatchesByFile` 的首见文件顺序;`grepSearchMeta`/`globSearchMeta` 在共享保留产出上的投影,`total` 报告截断前计数、`truncated` 被带过;保留过程施加的每行预览预算;序列化 meta 字节上限丢弃末尾组/路径同时保留单个超大条目;以及 `searchViewFromMeta` 对两种良好形状、零结果空卡片、以及每种畸形情形(非对象/数组 meta、缺失或误型的 `truncated`/`total`、未知 `shape`、畸形 `files` 条目、非字符串 `paths`)的收窄。`packages/fs/tool-fs-search/tests/tools.spec.ts` 钉住经真实工具注册表的接线:被截断的 `grep`/`glob` execute 在 `result.meta` 上产出 `SearchMeta`,`presentResult` 构建搜索视图(无 `content`),嵌套 `run_code` 分发不计算 meta 故 `presentResult` 回退,失败或跨形状或畸形结果回退到 generic 卡片。搜索包 `src` 上保持 per-file 100% 覆盖。
|
||||
|
||||
## 相关
|
||||
|
||||
- [工具调用呈现的带标签渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 用 `search` 结果标签扩展的 `card` 标签词汇。
|
||||
- [Canonical 工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投影所乘的 value/render/`presentationMeta` 划分;结构化值留在执行本地,卡片乘 `meta`。
|
||||
- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— 本 PR 在后端镜像的先例:工具把结果投影进 `presentationMeta` 与一个 `presentResult` 视图;搜索卡片的 web 消费方是与之类比的后续。
|
||||
@@ -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/feature/2026-07-30-versioned-gui-welcome-onboarding.md
|
||||
2026-07-30-versioned-gui-welcome-onboarding.md: 0705469e02ddb9068722ae5d500c151f077c83fd
|
||||
2026-07-30-versioned-gui-welcome-onboarding.zh.md: bdd21d635f824b8c4a4813e6bff7798b34ec9677
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Versioned GUI welcome onboarding
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-versioned-gui-welcome-onboarding.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The GUI's credential onboarding begins with a DeepSeek-specific readiness check, but the internal-test notice applies to every user and must precede provider setup even when a credential is already configured. Treating both as independent overlays permits simultaneous dialogs, while a process-local dismissal cannot distinguish a completed notice from a window closed before acknowledgement or intentionally present revised copy once.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`.
|
||||
|
||||
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete Chinese notice, its faithful English counterpart, the Continue labels, and `WELCOME_NOTICE_VERSION`. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content.
|
||||
|
||||
**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once.
|
||||
|
||||
**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
|
||||
|
||||
**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream.
|
||||
|
||||
**A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list.
|
||||
|
||||
**Persisting on render or window close** — rejected because observation is not acknowledgement and close delivery is unreliable. Only the explicit Continue commit may suppress the next launch.
|
||||
|
||||
**A generic public settings-exposure flag** — rejected because one product namespace does not justify widening every settings registrant's public configuration surface. The gateway keeps an explicit closed allowlist.
|
||||
|
||||
## Consequences
|
||||
|
||||
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 版本化 GUI 欢迎引导
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-versioned-gui-welcome-onboarding.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测试通知适用于每位用户,即使凭据已经配置,也必须先于提供方设置显示。若把两者作为独立浮层处理,多个对话框可能同时出现;仅存于进程内的关闭标记既无法区分通知已完成确认还是窗口在确认前已关闭,也无法在文案有意修订后重新显示一次通知。
|
||||
|
||||
## 决策
|
||||
|
||||
**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100`,`ui-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。
|
||||
|
||||
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整中文通知、忠实英文对侧文案、两种语言的「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。
|
||||
|
||||
**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。
|
||||
|
||||
**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
|
||||
|
||||
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。
|
||||
|
||||
**在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。
|
||||
|
||||
**在渲染或窗口关闭时持久化**:不予采用,因为看见通知不等于确认,窗口关闭事件也无法可靠送达。只有显式提交「继续」才能阻止通知在下次启动时再次显示。
|
||||
|
||||
**通用的公开设置暴露标志**:不予采用,因为一个产品 namespace 不足以证明应当扩大每个 settings 注册方的公开配置面。网关保留显式的封闭允许列表。
|
||||
|
||||
## 后果
|
||||
|
||||
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
|
||||
@@ -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/feature/2026-07-31-web-default-search.md
|
||||
2026-07-31-web-default-search.md: ddc047a963212cb228da67c6c33128877cacf92c
|
||||
2026-07-31-web-default-search.zh.md: 05c30b625953ccd54c127a97b646ad7db75f693b
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Default Web search in the Web/headless composition
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-web-default-search.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness had a complete Web capability family—provider registry, DeepSeek/Exa/Perplexity search providers, local fetch, stable model tools, and structured result presentation—but the shipped `dsh web` composition mounted none of it. The model could not discover current information unless a deployment supplied a custom overlay. Merely mounting the existing DeepSeek provider would not complete the WebUI path: the Models page stores `DEEPSEEK_API_KEY` through `ctx.credentials`, while the search provider froze only the process environment at plugin load, so a key entered or rotated in the running UI would not reach search.
|
||||
|
||||
## Decision
|
||||
|
||||
`apps/cli/config/web.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared overlay makes only `web_search` a default for browser and headless sessions; the TUI composition remains unchanged. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows.
|
||||
|
||||
DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered.
|
||||
|
||||
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
|
||||
|
||||
The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Mount only `dsh-tool-web`.** Rejected because stable schemas without registered providers would make every default call fail; enablement and backend availability are deliberately separate, but a shipped default must supply its intended implementations.
|
||||
|
||||
**Read `$DSH_HOME/.env` from `cordis.yml` or hoist it into `process.env`.** Rejected because the credential provider owns that document, environment values are read-only overrides, and hoisting would make stored keys unrotatable while bypassing the audited secret boundary.
|
||||
|
||||
**Freeze `process.env.DEEPSEEK_API_KEY` at provider load.** Rejected because the Web Models page writes through `ctx.credentials`; the product's documented first-run path must make the next operation work without a restart.
|
||||
|
||||
**Mount Web tools in `base.cordis.yml`.** Rejected because that would also change the TUI deployment. The browser and headless entries already share `web.cordis.yml`; they gain the capability together while TUI remains an explicit later decision.
|
||||
|
||||
**Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-local` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay.
|
||||
|
||||
## Consequences
|
||||
|
||||
Web/headless model requests carry only the `web_search` schema and search-only prompt guidance in native mode; Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The real-composition smoke test pins the absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Web/无头组合中的默认 Web 搜索
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-web-default-search.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
该 harness 已具备完整的 Web 能力体系:提供方注册表、DeepSeek、Exa 和 Perplexity 搜索提供方、本地抓取、稳定的面向模型工具,以及结构化结果呈现,但已交付的 `dsh web` 组合没有挂载其中任何一项。除非部署提供自定义覆盖层,否则模型无法发现最新信息。仅挂载现有 DeepSeek 提供方仍无法打通 WebUI 链路:Models 页面通过 `ctx.credentials` 存储 `DEEPSEEK_API_KEY`,而搜索提供方只会在插件加载时固定读取进程环境,因此在运行中的 UI 输入或轮换的密钥无法用于搜索。
|
||||
|
||||
## 决策
|
||||
|
||||
`apps/cli/config/web.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享覆盖层只将 `web_search` 设为浏览器与无头会话的默认工具;TUI 组合保持不变。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。
|
||||
|
||||
DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。
|
||||
|
||||
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
|
||||
|
||||
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付部署的默认值本就是 `danger-full-access`;未来如果产品采取受限网络策略,必须添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅挂载 `dsh-tool-web`。** 不予采纳:稳定的 schema 如果没有已注册提供方,每次默认调用都会失败。启用状态与后端可用性刻意分离,但已交付的默认配置必须提供其预期实现。
|
||||
|
||||
**从 `cordis.yml` 读取 `$DSH_HOME/.env`,或将其提升到 `process.env`。** 不予采纳:凭据提供方拥有该文件,环境变量值是只读覆盖;提升后存储的密钥将无法轮换,还会绕过经审计的密钥边界。
|
||||
|
||||
**在提供方加载时固定读取 `process.env.DEEPSEEK_API_KEY`。** 不予采纳:Web Models 页面通过 `ctx.credentials` 写入密钥;产品文档规定的首次运行路径必须保证下一次操作无需重启即可生效。
|
||||
|
||||
**在 `base.cordis.yml` 中挂载 Web 工具。** 不予采纳:这也会改变 TUI 部署。浏览器与无头入口已经共享 `web.cordis.yml`;两者会一同获得该能力,是否为 TUI 启用则仍留作后续显式决策。
|
||||
|
||||
**同时启用搜索和抓取。** 不予采纳:默认启用 `web_fetch` 会允许模型自行选择任意 URL,执行匿名出站 HTTP(S) 抓取。搜索负责发现信息;接受更广泛抓取范围的部署可以在覆盖层中选择启用 `dsh-web-fetch-local`,并将 `dsh-tool-web` 的 `fetch` 选项设为 `true`。
|
||||
|
||||
## 后果
|
||||
|
||||
Web/无头模型请求在原生模式下只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。真实组合冒烟测试固定了不提供 `web_fetch` 这一事实;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。
|
||||
@@ -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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.md: 7759d0b6a8d522d6f853be0cd31a0b3909292446
|
||||
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 893b91c2ad3943cce9f64702e52afbb8075b907c
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
|
||||
|
||||
The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
|
||||
|
||||
**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `<launch dir>/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
|
||||
**Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `<launch dir>/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts).
|
||||
|
||||
**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
|
||||
|
||||
@@ -16,11 +16,11 @@ The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-
|
||||
|
||||
Two independent changes, one per gap.
|
||||
|
||||
### One global storage root, resolved once
|
||||
### One global storage root (shipped, amended form); resolved once at construction (still open)
|
||||
|
||||
- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
|
||||
- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
|
||||
- Pre-release stance applies: no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
|
||||
- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row, with the same `!!js` IIFE the session root uses (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The user ruled this form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The verbatim duplication of the expression against the session root (`base.cordis.yml`) is a known cost — acceptable at two consumers; a third `$DSH_HOME`-derived root triggers extracting a single source (a launcher variable or a shared expression). The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home.
|
||||
- **Still open**: `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
|
||||
- Pre-release stance applies (and was executed): no migration shim. A deployment that cached under `<cwd>/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
|
||||
|
||||
### Declared derived media: reset instead of reject
|
||||
|
||||
@@ -31,7 +31,9 @@ Two independent changes, one per gap.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
|
||||
**Keep per-launch-directory `.storages` (the pre-change status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
|
||||
|
||||
**Launcher patch + a `storageRoot` profile key (this section's original form)** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
|
||||
|
||||
**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
|
||||
|
||||
@@ -45,7 +47,7 @@ Two independent changes, one per gap.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
|
||||
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).
|
||||
- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
|
||||
- The same damage to `workspace.json` still fails boot loudly.
|
||||
- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: proposed
|
||||
|
||||
持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
|
||||
|
||||
**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'`(`apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
|
||||
**文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。
|
||||
|
||||
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
|
||||
|
||||
@@ -16,11 +16,11 @@ Status: proposed
|
||||
|
||||
两个独立改动,一个缺口一个。
|
||||
|
||||
### 全局唯一存储根,构造时 resolve 一次
|
||||
### 全局唯一存储根(已落地,形态修正);构造时 resolve 一次(仍开放)
|
||||
|
||||
- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
|
||||
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
|
||||
- 适用 pre-release 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
|
||||
- **已落地**:出厂 Web overlay 直接在 `storage-json` 行内用与会话根同一段 `!!js` IIFE 把 `root` 锚定到 `$DSH_HOME/storages`(默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩;目录名不带点——home 本身已是隐藏树)。用户拍板采用此形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives);按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。该表达式与 sessions 根(`base.cordis.yml`)逐字重复是已知代价——两个消费者尚可接受;出现第三个 `$DSH_HOME` 派生根时提取单一来源(launcher 变量或共享表达式)。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home。
|
||||
- **仍开放**:`JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
|
||||
- 适用 pre-release 立场(已按此执行):不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
|
||||
|
||||
### 声明派生介质:损坏时重置而非拒绝
|
||||
|
||||
@@ -31,7 +31,9 @@ Status: proposed
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
|
||||
**保持按启动目录的 `.storages`(改动前现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
|
||||
|
||||
**launcher patch + `storageRoot` profile 键(本节初版提案形态)**——未采:一行 yml `!!js` 表达式即达全局根,与会话根的既有分层完全一致;launcher patch 多引入一个改写点,profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。
|
||||
|
||||
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
|
||||
|
||||
@@ -45,7 +47,7 @@ Status: proposed
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
|
||||
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`)——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)。
|
||||
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
|
||||
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
|
||||
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -5,3 +5,5 @@
|
||||
# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the
|
||||
# in-repo form stays LF; CRLF becomes checkout-time presentation only.
|
||||
* text=auto eol=lf
|
||||
|
||||
*.pdf -text
|
||||
|
||||
@@ -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 README.md
|
||||
README.md: baf5d79b157ae845cc837261452853afd48dbe46
|
||||
README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd
|
||||
README.md: b17098a4fee2354dfb2015afe34582f725b59df1
|
||||
README.zh.md: 9a17f76608e23719d27e9eb43d01582987adb3bf
|
||||
|
||||
@@ -8,13 +8,13 @@ It uses an architecture where **everything is a plugin**.
|
||||
|
||||
## Internal testing notice
|
||||
|
||||
Thank you for making time to try DeepSeek Harness.
|
||||
Thank you for taking the time to try DeepSeek Harness.
|
||||
|
||||
This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.
|
||||
This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.
|
||||
|
||||
“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.
|
||||
“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.
|
||||
|
||||
We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our <a href="https://wj.qq.com/s2/27234598/03eb/">WeCom group</a> and tell us about your experience. Every report will help us refine it.
|
||||
We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in our <a href="https://wj.qq.com/s2/27234598/03eb/">WeCom group</a>. Every piece of feedback helps us refine it.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
|
||||
|
||||
感谢您愿意拨冗试用 DeepSeek Harness。
|
||||
|
||||
目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。
|
||||
目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
|
||||
“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
|
||||
我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -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 apps/cli/README.md
|
||||
README.md: c36a75fc61fd7118f48c9b68be3144177df19534
|
||||
README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
|
||||
README.md: fc5bce195fb10872c605cada1bcb3ed79380265c
|
||||
README.zh.md: 2fb1272231abb02e145e5c9925362de27307ca86
|
||||
|
||||
@@ -21,6 +21,8 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
The Web/headless composition also registers only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The TUI composition does not mount Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
|
||||
|
||||
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
|
||||
|
||||
@@ -21,6 +21,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
Web/无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。TUI 组合默认不挂载 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
|
||||
|
||||
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
|
||||
|
||||
@@ -80,6 +80,28 @@
|
||||
- id: fs-local
|
||||
disabled: true
|
||||
|
||||
# The Web/headless product enables only the stable web_search model surface.
|
||||
# DeepSeek search resolves the same DEEPSEEK_API_KEY credential the Models page
|
||||
# manages for chat, at each search; its Messages endpoint is separate from the
|
||||
# chat-completions endpoint. Fetch remains disabled and no default fetch
|
||||
# provider is mounted.
|
||||
- insert:
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetch: false
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dshClient` rows are the browser roster the modules node half scans into
|
||||
@@ -97,7 +119,8 @@
|
||||
- id: storage-json
|
||||
name: '@deepseek-ai/dsh-storage-json'
|
||||
config:
|
||||
root: './.storages'
|
||||
root: !!js >-
|
||||
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'storages') })()
|
||||
|
||||
- id: storage-domain
|
||||
name: '@deepseek-ai/dsh-storage-domain'
|
||||
|
||||
@@ -112,11 +112,14 @@
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
|
||||
@@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -26,7 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
const browserConsole: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
tripwire = watchConsole(page)
|
||||
@@ -42,16 +48,61 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
it('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
expect(await dialog.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
|
||||
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
|
||||
expect(await welcome.locator('button').count()).toBe(1)
|
||||
|
||||
const mask = page.locator('[class*="onboardingMask"]')
|
||||
expect(await mask.count()).toBe(1)
|
||||
const maskStyles = await mask.evaluate((mask) => {
|
||||
const style = getComputedStyle(mask)
|
||||
const rect = mask.getBoundingClientRect()
|
||||
return {
|
||||
position: style.position,
|
||||
left: style.left,
|
||||
right: style.right,
|
||||
top: style.top,
|
||||
bottom: style.bottom,
|
||||
background: style.backgroundColor,
|
||||
backdropFilter: style.backdropFilter,
|
||||
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
||||
}
|
||||
})
|
||||
expect(maskStyles).toEqual({
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
top: '80px',
|
||||
bottom: '0px',
|
||||
background: 'rgba(0, 0, 0, 0.24)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
||||
})
|
||||
|
||||
// Closing the process/page before acknowledgement writes nothing, so the
|
||||
// same durable profile presents the notice again after reload.
|
||||
const firstReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
await dialog.getByRole('button', { name: '前往配置' }).click()
|
||||
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -78,6 +129,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
|
||||
|
||||
const secondReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
// A different stored copy version represents an intentional version bump:
|
||||
// the welcome step returns even though the credential is already ready.
|
||||
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
|
||||
}])
|
||||
const thirdReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
@@ -86,6 +160,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,10 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -135,6 +139,19 @@ export interface LaunchOptions {
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/**
|
||||
* Patch the shipped DeepSeek search row to a deterministic endpoint and
|
||||
* credential reference. Browser search scenarios keep the real provider and
|
||||
* credentials seam while avoiding external search traffic and ambient keys.
|
||||
*/
|
||||
deepSeekSearch?: {
|
||||
/** Anthropic-compatible base URL; the provider appends `/messages`. */
|
||||
baseURL: string
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -200,9 +217,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...surfacePatches,
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's './.storages' yml default is cwd-relative and resolves
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
|
||||
// to an absolute temp root (removed with the workspace at close) so tests
|
||||
// never write the user's harness home.
|
||||
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
|
||||
// Skill discovery is model-visible input. Pin every host-level root inside
|
||||
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
|
||||
@@ -241,6 +258,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
: [],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
baseURL: options.deepSeekSearch.baseURL,
|
||||
},
|
||||
}],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
@@ -266,6 +292,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
}])
|
||||
}
|
||||
const boundPort = ctx.get('httpServer')?.port
|
||||
if (boundPort === undefined) {
|
||||
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
|
||||
|
||||
@@ -188,8 +188,12 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
|
||||
|
||||
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
|
||||
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
|
||||
interface NativeProviderRequest {
|
||||
messages?: { role?: string; content?: string }[]
|
||||
tools?: { function?: { name?: string } }[]
|
||||
}
|
||||
let resolveProviderRequest!: (request: NativeProviderRequest) => void
|
||||
const providerRequest = new Promise<NativeProviderRequest>((resolve) => {
|
||||
resolveProviderRequest = resolve
|
||||
})
|
||||
const provider = createServer((request, response) => {
|
||||
@@ -197,7 +201,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
|
||||
resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
@@ -256,6 +260,13 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
"role": "user",
|
||||
}
|
||||
`)
|
||||
expect(captured.tools?.map(tool => tool.function?.name)
|
||||
.filter(name => name === 'web_search' || name === 'web_fetch'))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
"web_search",
|
||||
]
|
||||
`)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
- dialog "添加一个 API Key 开始使用":
|
||||
- region "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- button "稍后配置":
|
||||
- img
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- button "稍后配置"
|
||||
- button "前往配置"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- region "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。
|
||||
- paragraph: 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
- paragraph:
|
||||
- text: 我们尤其希望听见那些失败、困惑与不顺手的时刻——
|
||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
- button "继续"
|
||||
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
12
apps/web/tests/snapshots/web-search-round/session.jsonl
Normal file
@@ -0,0 +1,12 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785456000000,"cwd":"{{cwd}}"}
|
||||
{"type":"user/message","seq":0,"time":1785456000001,"data":{"content":[{"type":"text","text":"Use web_search to search exactly \"DeepSeek Harness snapshot search\". Then reply exactly SEARCH_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":1,"time":1785456000002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":2,"time":1785456000003,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_web_search","name":"web_search","argumentsDelta":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}
|
||||
{"type":"assistant/chunk","seq":3,"time":1785456000004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_web_search","name":"web_search","arguments":"{\"query\":\"DeepSeek Harness snapshot search\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":4,"time":1785456000005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785456000006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1785456000007,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1785456000008,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"SEARCH_DONE"}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1785456000009,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SEARCH_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1785456000010,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1785456000011,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
35
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
35
apps/web/tests/snapshots/web-search-round/ui.expected.md
Normal file
@@ -0,0 +1,35 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use web_search to search exactly" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- img
|
||||
- text: Search DeepSeek Harness snapshot search
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Snapshot Search Result":
|
||||
- /url: https://docs.example.test/search
|
||||
- text: Snapshot search excerpt. 2026-07-31
|
||||
- paragraph: SEARCH_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 0% Input 22 tok · Output 7 tok
|
||||
207
apps/web/tests/web-search-round.e2e.ts
Normal file
207
apps/web/tests/web-search-round.e2e.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
// Web e2e scenario for the shipped default search composition. A real browser
|
||||
// drives `web_search`; the model stream is replayed while the real DeepSeek
|
||||
// provider calls a deterministic local Anthropic-compatible endpoint through
|
||||
// the real credentials service.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/web-search-round', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/web-search-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/web-search-round/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const QUERY = 'DeepSeek Harness snapshot search'
|
||||
const PROMPT = `Use web_search to search exactly "${QUERY}". Then reply exactly SEARCH_DONE and stop.`
|
||||
const SEARCH_CREDENTIAL_REF = credentialRef('DSH_WEB_SEARCH_E2E_KEY')
|
||||
const SEARCH_CREDENTIAL = 'snapshot-search-key'
|
||||
const RESULT_URL = 'https://docs.example.test/search'
|
||||
|
||||
interface CapturedSearchRequest {
|
||||
path: string
|
||||
apiKey: string | undefined
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/** Start the deterministic DeepSeek Messages double used by the real provider. */
|
||||
async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ server: Server; baseURL: string }> {
|
||||
const server = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
captured.push({
|
||||
path: request.url ?? '',
|
||||
apiKey: typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : undefined,
|
||||
body: JSON.parse(body) as unknown,
|
||||
})
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Found one source.',
|
||||
citations: [{
|
||||
type: 'web_search_result_location',
|
||||
url: RESULT_URL,
|
||||
cited_text: 'Snapshot search excerpt.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
type: 'web_search_tool_result',
|
||||
content: [{
|
||||
type: 'web_search_result',
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
page_age: '2026-07-31',
|
||||
}],
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address() as AddressInfo
|
||||
return { server, baseURL: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
describe('web e2e: shipped default web search', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let searchServer: Server | undefined
|
||||
let searchBaseURL: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const searchRequests: CapturedSearchRequest[] = []
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const search = await startSearchServer(searchRequests)
|
||||
searchServer = search.server
|
||||
searchBaseURL = search.baseURL
|
||||
scaffold = await launchWebScaffold({
|
||||
deepSeekSearch: {
|
||||
baseURL: search.baseURL,
|
||||
apiKeyEnv: SEARCH_CREDENTIAL_REF,
|
||||
},
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
await scaffold.ctx.credentials.set(SEARCH_CREDENTIAL_REF, SEARCH_CREDENTIAL)
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (searchServer === undefined) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
searchServer.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('drives the recorded search to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-drive'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('uses the real provider and persists the structured result', () => {
|
||||
expect(searchRequests).toHaveLength(1)
|
||||
expect(searchRequests[0]).toMatchObject({
|
||||
path: '/messages',
|
||||
apiKey: SEARCH_CREDENTIAL,
|
||||
body: {
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Perform a web search for the query: ${QUERY}` }],
|
||||
}],
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
},
|
||||
})
|
||||
|
||||
const auxiliaryRequest = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'web/deepseek-search-llm-request' }> =>
|
||||
event.type === 'web/deepseek-search-llm-request',
|
||||
)
|
||||
expect(auxiliaryRequest?.data).toEqual({
|
||||
endpoint: `${searchBaseURL}/messages`,
|
||||
apiVersion: '2023-06-01',
|
||||
body: searchRequests[0]?.body,
|
||||
})
|
||||
|
||||
const searchCall = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> =>
|
||||
event.type === 'tool/call' && event.data.name === 'web_search',
|
||||
)
|
||||
if (searchCall === undefined) throw new Error('the replayed turn did not call web_search')
|
||||
const searchResult = sessionEvents.find(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/result' }> =>
|
||||
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
|
||||
)
|
||||
if (searchResult === undefined) throw new Error('web_search produced no durable result')
|
||||
const content = searchResult.data.message.content[0]
|
||||
expect(content.isError).toBe(false)
|
||||
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toContain(`[Snapshot Search Result](${RESULT_URL})`)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
sources: [{
|
||||
url: RESULT_URL,
|
||||
title: 'Snapshot Search Result',
|
||||
snippet: 'Snapshot search excerpt.',
|
||||
publishedAt: '2026-07-31',
|
||||
}],
|
||||
truncated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the settled search card aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
|
||||
await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,7 @@
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/composer-draft-scroll.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/web-search-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
|
||||
@@ -1729,6 +1729,8 @@ export interface Config {
|
||||
grepMaxMatches?: number
|
||||
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
|
||||
grepMaxLineBytes?: number
|
||||
/** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
|
||||
searchMetaMaxBytes?: number
|
||||
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
|
||||
rawOutputMaxBytes?: number
|
||||
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
|
||||
@@ -1736,7 +1738,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../packages/fs/tool-fs-search/src/index.ts)
|
||||
Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-goal`
|
||||
|
||||
@@ -1992,7 +1994,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:584`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui`
|
||||
|
||||
@@ -2162,8 +2164,10 @@ Requires: `web`
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
|
||||
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey?: string
|
||||
/** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv?: string
|
||||
/** Anthropic-compatible endpoint base; `/messages` is appended. */
|
||||
baseURL?: string
|
||||
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
|
||||
@@ -2177,7 +2181,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:38`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
|
||||
@@ -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 docs/cookbook/adding-a-tool.md
|
||||
adding-a-tool.md: a85de0feeeee307ac645f8c2967bb44521d059a8
|
||||
adding-a-tool.zh.md: 8e4e6a1128f4f2ad3b4d42c2b88edaf4d8d89af1
|
||||
adding-a-tool.md: 80625b5ec64aca8f8cda8a39048ba1c13fe57b2b
|
||||
adding-a-tool.zh.md: 7d426ed7f0e5c9147d29ac7f6deb15ec27e36288
|
||||
|
||||
@@ -78,6 +78,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha
|
||||
- `generic` supplies an optional title and content.
|
||||
- `terminal` supplies raw output and optional exit metadata; each UI renders its capable or fallback view.
|
||||
- `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card.
|
||||
- `search` supplies a discovery result reconstructed from persisted `result.meta`: grouped-by-file matches (`shape: 'matches'`, grep) or a flat path list (`shape: 'paths'`, glob), plus `truncated`/`total` so a UI never presents a capped result as complete. The view carries no result text (a UI without a search card falls back to the raw result content), and there is no `search` call view — a discovery call's pending state stays a generic card, since matches exist only after `execute`. (tool-fs-search `grep`/`glob`.)
|
||||
- `web` supplies a completed web retrieval, discriminated by `kind: 'search' | 'fetch'` (the structured search sources or the fetch summary), derived from `result.meta`; it carries no body copy, so a UI without the `web` capability falls back to the raw result content. (tool-web `web_search`/`web_fetch`.)
|
||||
|
||||
Hard rules (they bite if broken):
|
||||
|
||||
@@ -78,6 +78,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的
|
||||
- `generic` 提供可选的标题和内容。
|
||||
- `terminal` 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。
|
||||
- `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。
|
||||
- `search` 提供从持久化 `result.meta` 重建的发现型结果:按文件分组的匹配(`shape: 'matches'`,grep)或扁平路径列表(`shape: 'paths'`,glob),外加 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现。该视图不携带结果文本(无 search 卡片的 UI 回退到原始结果内容),也没有 `search` 调用视图——发现型调用的 pending 状态保持为 generic 卡片,因为匹配只在 `execute` 之后才存在。(tool-fs-search 的 `grep`/`glob`。)
|
||||
- `web` 提供已完成的 web 检索,以 `kind: 'search' | 'fetch'` 区分(结构化的搜索来源或抓取摘要),由 `result.meta` 派生;它不携带正文副本,因此不具备 `web` 能力的 UI 回退到原始结果内容。(tool-web `web_search`/`web_fetch`。)
|
||||
|
||||
硬性规则(违反会出问题):
|
||||
|
||||
@@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:162`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/code-dispatch-log` — waterfall
|
||||
|
||||
@@ -962,7 +962,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
|
||||
|
||||
Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/execute` — waterfall
|
||||
|
||||
@@ -984,7 +984,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:119`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/post-execute` — waterfall
|
||||
|
||||
@@ -1007,7 +1007,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
|
||||
|
||||
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/pre-execute` — waterfall
|
||||
|
||||
@@ -1028,7 +1028,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
|
||||
|
||||
Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/result` — emit
|
||||
|
||||
@@ -1047,7 +1047,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `workflow/*`
|
||||
|
||||
|
||||
@@ -2313,7 +2313,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:706`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.tui` — `TuiExtensionService` (abstract seam)
|
||||
|
||||
|
||||
65905
docs/cordis-paper.pdf
Normal file
65905
docs/cordis-paper.pdf
Normal file
File diff suppressed because one or more lines are too long
@@ -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 docs/core-data-structures/tools.md
|
||||
tools.md: 62acd00a3afe50c90b2cab0bb0f170ab82852a4f
|
||||
tools.zh.md: 1ec638d6062d6496aebc019e531126343d6ead28
|
||||
tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85
|
||||
tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6
|
||||
|
||||
@@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
|
||||
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:
|
||||
|
||||
- `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file).
|
||||
- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet.
|
||||
- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no `card` call-time analogue (their pending state stays a generic card, since the structured result exists only after `execute`).
|
||||
|
||||
`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views.
|
||||
|
||||
|
||||
@@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
|
||||
工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发:
|
||||
|
||||
- `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。
|
||||
- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。
|
||||
- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索和 web 检索都没有 `card` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为结构化结果只在 `execute` 之后才存在)。
|
||||
|
||||
`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm), [`tui`](../packages/ui/tui) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||
@@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:162`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:119`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:124`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
|
||||
@@ -66,14 +66,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event string | Dispatchers | Listeners |
|
||||
| --- | --- | --- |
|
||||
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission` |
|
||||
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
|
||||
| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
|
||||
| `internal/status` | - | [`agent`](../packages/core/agent) |
|
||||
| `locale/change` | `locale` (`emit`) | `locale` |
|
||||
| `models/changed` | `runtime` (`emit`) | `ui-models` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission` |
|
||||
| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
|
||||
| `slash/input-begin-command` | - | `ui-conversation` |
|
||||
| `slash/input-consume-token` | - | `ui-conversation` |
|
||||
| `slash/input-insert-reference` | - | `ui-conversation` |
|
||||
|
||||
@@ -365,11 +365,13 @@ flowchart TD
|
||||
pkg_client_ui_models --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_connection
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
pkg_client_ui_settings_general --> pkg_client_runtime
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings_general --> pkg_client_web_react
|
||||
pkg_client_ui_settings_general --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_locale
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
@@ -429,8 +431,6 @@ flowchart TD
|
||||
pkg_web_fetch_local --> pkg_invariants
|
||||
pkg_web_fetch_local --> pkg_timeout
|
||||
pkg_web_fetch_local --> pkg_web
|
||||
pkg_web_search_deepseek --> pkg_invariants
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_web_search_exa --> pkg_invariants
|
||||
pkg_web_search_exa --> pkg_web
|
||||
pkg_web_search_perplexity --> pkg_invariants
|
||||
@@ -526,6 +526,11 @@ flowchart TD
|
||||
pkg_compact_basic --> pkg_llm
|
||||
pkg_compact_basic --> pkg_session
|
||||
pkg_compact_basic --> pkg_token_meter
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_invariants
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_spill_local --> pkg_invariants
|
||||
pkg_spill_local --> pkg_spill
|
||||
pkg_hook_protocol --> pkg_bash
|
||||
@@ -1086,7 +1091,7 @@ flowchart TD
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1102,7 +1107,6 @@ flowchart TD
|
||||
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
@@ -1127,6 +1131,7 @@ flowchart TD
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
|
||||
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
|
||||
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
|
||||
@@ -643,3 +643,14 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `web/*`
|
||||
|
||||
#### `web/deepseek-search-llm-request` — log-only
|
||||
|
||||
```ts persistence-catalog
|
||||
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
|
||||
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
||||
{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}
|
||||
{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
|
||||
@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
|
||||
'settings.describe',
|
||||
'settings.update',
|
||||
'settings.replace',
|
||||
'settings.mutate',
|
||||
'credentials.describe',
|
||||
'credentials.set',
|
||||
'credentials.unset',
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('connection node half', () => {
|
||||
// passed), but each privileged method stays loopback-only and 403s.
|
||||
for (const method of [
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
@@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Reads are as privileged as writes: describe returns the exposed
|
||||
// configuration, and credentials.describe probes arbitrary env-var names.
|
||||
for (const method of [
|
||||
'settings.describe', 'settings.update', 'settings.replace',
|
||||
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'host.pickDirectory', 'host.openPath',
|
||||
]) {
|
||||
|
||||
@@ -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-models/README.md
|
||||
README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3
|
||||
README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736
|
||||
README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
|
||||
README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
|
||||
@@ -1,7 +1,99 @@
|
||||
.dialog {
|
||||
width: min(420px, 100%);
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(104px, 18vh, 156px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 16px 0 0;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.description { animation-delay: 80ms; }
|
||||
.actions { animation-delay: 120ms; }
|
||||
|
||||
@keyframes credential-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.description,
|
||||
.actions {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 64px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.later {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Official-DeepSeek first-run dialog. Readiness comes from the same
|
||||
* Official-DeepSeek first-run step. Readiness comes from the same
|
||||
* provider/settings/credential join as the Models page; the prompt only
|
||||
* routes the user to that page's single credential editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
|
||||
import { deepSeekReadiness } from './store.ts'
|
||||
@@ -37,29 +37,35 @@ function assertNever(_value: never): never {
|
||||
* Prompt a first-run user to open Models while the official adapter exists
|
||||
* and its effective credential is not configured.
|
||||
* @param props - settings-shell owner state and Models feature dependencies.
|
||||
* @returns the controlled modal or null when onboarding needs no intervention.
|
||||
* @returns the onboarding page or null when onboarding needs no intervention.
|
||||
*/
|
||||
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
|
||||
const { active, openSection, controller, useSnapshot, t } = props
|
||||
const { complete, openSection, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const readiness = deepSeekReadiness(state)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !dismissed && state.status === 'idle') void controller.load()
|
||||
}, [active, controller, dismissed, state.status])
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
const close = (): void => {
|
||||
setDismissed(true)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (
|
||||
readiness.kind === 'adapter-absent'
|
||||
|| readiness.kind === 'configured'
|
||||
|| readiness.kind === 'unavailable'
|
||||
) complete()
|
||||
}, [complete, readiness.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
|
||||
}, [readiness.kind])
|
||||
|
||||
const openModels = (): void => {
|
||||
close()
|
||||
complete()
|
||||
openSection('models')
|
||||
}
|
||||
|
||||
if (!active || dismissed) return null
|
||||
|
||||
switch (readiness.kind) {
|
||||
case 'loading':
|
||||
case 'adapter-absent':
|
||||
@@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={close}
|
||||
title={t('onboardingTitle')}
|
||||
closeLabel={t('onboardingLater')}
|
||||
description={t('onboardingDescription')}
|
||||
className={styles['dialog'] as string}
|
||||
footer={(
|
||||
<Button
|
||||
variant="primary"
|
||||
className={styles['primary']}
|
||||
autoFocus
|
||||
onClick={openModels}
|
||||
>
|
||||
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
|
||||
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2
|
||||
ref={titleRef}
|
||||
id="deepseek-onboarding-title"
|
||||
className={styles['title']}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('onboardingTitle')}
|
||||
</h2>
|
||||
<p className={styles['description']}>{t('onboardingDescription')}</p>
|
||||
<div className={styles['actions']}>
|
||||
<Button variant="ghost" className={styles['later']} onClick={complete}>
|
||||
{t('onboardingLater')}
|
||||
</Button>
|
||||
<Button variant="primary" className={styles['primary']} onClick={openModels}>
|
||||
{t('onboardingGoToSettings')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ function fail<T>(message: string): RpcResponse<T> {
|
||||
|
||||
function harness(options: {
|
||||
provider?: boolean
|
||||
providerActive?: boolean
|
||||
providerSettingsNs?: string
|
||||
providerActive?: boolean
|
||||
settingsNamespace?: boolean
|
||||
apiKeyEnv?: string | null
|
||||
literal?: boolean
|
||||
@@ -40,9 +40,7 @@ function harness(options: {
|
||||
const face = {
|
||||
llm: {
|
||||
providers: () => {
|
||||
if (options.providersReject === true) {
|
||||
return Promise.reject(new Error('provider transport unavailable'))
|
||||
}
|
||||
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
|
||||
return Promise.resolve(ok({
|
||||
providers: options.provider === false
|
||||
? []
|
||||
@@ -91,9 +89,11 @@ function harness(options: {
|
||||
}
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
const openSection = vi.fn()
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: DeepSeekOnboardingDialogProps = {
|
||||
active: true,
|
||||
stepId: 'deepseek-official',
|
||||
complete,
|
||||
openSection,
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
@@ -101,36 +101,36 @@ function harness(options: {
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => en[key],
|
||||
}
|
||||
return { controller, openSection, props, configure: () => { fileConfigured = true } }
|
||||
return { controller, complete, openSection, props, configure: () => { fileConfigured = true } }
|
||||
}
|
||||
|
||||
describe('DeepSeekOnboardingDialog', () => {
|
||||
it('loads on first entry and presents one accessible route to Models', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy()
|
||||
expect(screen.getByText(en.onboardingDescription)).toBeTruthy()
|
||||
const action = screen.getByRole('button', { name: en.onboardingGoToSettings })
|
||||
expect(action).toBeTruthy()
|
||||
expect(document.activeElement).toBe(action)
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle }))
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the Models section and dismisses the prompt', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).toHaveBeenCalledWith('models')
|
||||
expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows configure-later dismissal without opening settings', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: en.onboardingLater }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
expect(h.openSection).not.toHaveBeenCalled()
|
||||
view.unmount()
|
||||
}
|
||||
@@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
]) {
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
|
||||
view.unmount()
|
||||
}
|
||||
})
|
||||
@@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => {
|
||||
it('closes when an external credential invalidation refreshes the shared join', async () => {
|
||||
const h = harness()
|
||||
render(<DeepSeekOnboardingDialog {...h.props} />)
|
||||
await screen.findByRole('dialog')
|
||||
await screen.findByRole('region')
|
||||
h.configure()
|
||||
await act(async () => { await h.controller.load() })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() })
|
||||
})
|
||||
|
||||
it('stays hidden while the onboarding owner is inactive', async () => {
|
||||
const h = harness()
|
||||
const view = render(<DeepSeekOnboardingDialog {...h.props} active={false} />)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
view.rerender(<DeepSeekOnboardingDialog {...h.props} active />)
|
||||
expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy()
|
||||
await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() })
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6
|
||||
README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
|
||||
README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
|
||||
README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages.
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-general",
|
||||
"description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
|
||||
"description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -26,7 +26,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-connection"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -35,22 +36,30 @@
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(640px, calc(100vw - 64px));
|
||||
max-height: 100vh;
|
||||
padding: clamp(64px, 9vh, 104px) 0 40px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 42px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback {
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.feedback strong {
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.primary {
|
||||
min-width: 120px;
|
||||
transition: transform 140ms var(--welcome-ease-out);
|
||||
}
|
||||
|
||||
.primary:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: welcome-enter 280ms var(--welcome-ease-out) both;
|
||||
}
|
||||
|
||||
.title { animation-delay: 40ms; }
|
||||
.opening { animation-delay: 80ms; }
|
||||
.status { animation-delay: 120ms; }
|
||||
.reflection { animation-delay: 160ms; }
|
||||
.feedback { animation-delay: 200ms; }
|
||||
.footer { animation-delay: 240ms; }
|
||||
|
||||
@keyframes welcome-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand,
|
||||
.title,
|
||||
.opening,
|
||||
.status,
|
||||
.reflection,
|
||||
.feedback,
|
||||
.footer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.primary {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
width: calc(100vw - 40px);
|
||||
padding-top: 38px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.opening {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.reflection {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Product-wide, versioned first-run welcome step. */
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import css from './WelcomeNotice.module.css'
|
||||
|
||||
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
|
||||
const index = paragraph.indexOf(emphasis)
|
||||
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
|
||||
if (index < 0) return paragraph
|
||||
return (
|
||||
<>
|
||||
{paragraph.slice(0, index)}
|
||||
<strong>{emphasis}</strong>
|
||||
{paragraph.slice(index + emphasis.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
|
||||
export interface WelcomeNoticeInjected {
|
||||
controller: WelcomeNoticeStore
|
||||
useSnapshot: SnapshotSelectorHook<WelcomeNoticeState>
|
||||
}
|
||||
|
||||
/** Coordinator owner props plus the welcome step's injected face. */
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
|
||||
|
||||
/** Render the mandatory notice until its current version commits durably. */
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
const finished = useRef(false)
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null)
|
||||
const finish = useCallback((): void => {
|
||||
if (finished.current) return
|
||||
finished.current = true
|
||||
complete()
|
||||
}, [complete])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'idle') void controller.load()
|
||||
}, [controller, state.status])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.acknowledged) finish()
|
||||
}, [finish, state.acknowledged])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
|
||||
}, [state.acknowledged, state.status])
|
||||
|
||||
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
|
||||
|
||||
const acknowledge = async (): Promise<void> => {
|
||||
if (await controller.acknowledge()) finish()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
|
||||
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
|
||||
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
|
||||
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
|
||||
<p className={css.status}>{t('welcome.paragraph.1')}</p>
|
||||
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
|
||||
<p className={css.feedback}>
|
||||
{emphasizedFeedback(t('welcome.paragraph.3'), t('welcome.feedbackEmphasis'))}
|
||||
</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
|
||||
<div className={css.footer}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.primary}
|
||||
disabled={state.status === 'saving'}
|
||||
onClick={() => { void acknowledge() }}
|
||||
>
|
||||
{t('welcome.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -7,12 +7,18 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
|
||||
import { GeneralSection } from './GeneralSection.tsx'
|
||||
import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
|
||||
import { WelcomeNotice } from './WelcomeNotice.tsx'
|
||||
import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
|
||||
import { en, zh, type SettingsKey } from './locales.ts'
|
||||
|
||||
export type {
|
||||
@@ -21,6 +27,8 @@ export type {
|
||||
export type {
|
||||
GeneralSectionComponentProps,
|
||||
} from './GeneralSection.tsx'
|
||||
export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
|
||||
export type { WelcomeNoticeState } from './welcome-store.ts'
|
||||
export type { SettingsKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -38,7 +46,7 @@ const NS = 'settings'
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration goes through declaration-aware deferral.
|
||||
*/
|
||||
export const inject = ['slots', 'locale']
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Register the `settings` dictionaries, the chrome content, and the General
|
||||
@@ -52,6 +60,25 @@ export function apply(ctx: ClientContext): void {
|
||||
// seat, and the nav label is a thunk the owner resolves per render — no
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const welcomeController = new WelcomeNoticeStore(connection.api)
|
||||
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
useSnapshot: useWelcomeSnapshot,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
|
||||
refreshWelcomeIfLoaded(welcomeController)
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-general: welcome invalidations')
|
||||
ctx.effect(() => {
|
||||
const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
|
||||
ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
|
||||
@@ -68,11 +95,20 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.onboarding',
|
||||
id: 'welcome-notice',
|
||||
order: -100,
|
||||
locale: NS,
|
||||
inject: welcomeInjected,
|
||||
}, WelcomeNotice))
|
||||
return () => {
|
||||
trigger.dispose()
|
||||
header.dispose()
|
||||
close.dispose()
|
||||
general.dispose()
|
||||
welcome.dispose()
|
||||
}
|
||||
}, 'ui-settings-general: chrome and section registrations')
|
||||
}, 'ui-settings-general: chrome, section, and onboarding registrations')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
|
||||
/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
|
||||
import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
@@ -6,6 +7,14 @@ export const zh = {
|
||||
'title': '设置',
|
||||
'close': '关闭',
|
||||
'general.nav': '通用设置',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
|
||||
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
|
||||
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
|
||||
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
|
||||
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
|
||||
'welcome.error': '暂时无法保存确认状态,请重试。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings namespace key union. */
|
||||
@@ -17,4 +26,12 @@ export const en = {
|
||||
'title': 'Settings',
|
||||
'close': 'Close',
|
||||
'general.nav': 'General',
|
||||
'welcome.title': WELCOME_NOTICE_COPY.en.title,
|
||||
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
|
||||
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
|
||||
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
|
||||
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
|
||||
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
|
||||
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
|
||||
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
|
||||
} satisfies Record<SettingsKey, string>
|
||||
|
||||
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
108
packages/client/ui-settings-general/src/client/welcome-store.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** Durable welcome-notice state over the Host settings document. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../onboarding-copy.ts'
|
||||
|
||||
/** State rendered by the welcome step. */
|
||||
export interface WelcomeNoticeState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'saving' | 'error'
|
||||
acknowledged: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
if (typeof view.value !== 'object' || view.value === null) return undefined
|
||||
const value = (view.value as Record<string, unknown>)[WELCOME_NOTICE_ACK_FIELD]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates welcome acknowledgement reads and the sole durable write. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
status: 'idle', acknowledged: false, error: null,
|
||||
})
|
||||
|
||||
private generation = 0
|
||||
|
||||
/** @param api - settings wire face used for durable reads and writes. */
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
|
||||
/** Load the current acknowledgement from the Host settings document. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
const view = response.result.value.namespaces.find(
|
||||
candidate => candidate.ns === WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
)
|
||||
if (view === undefined) throw new Error('welcome acknowledgement settings are unavailable')
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = acknowledgementOf(view) === WELCOME_NOTICE_VERSION
|
||||
state.error = null
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version. The path mutation is idempotent across tabs and
|
||||
* preserves every sibling setting; failure leaves the step unacknowledged.
|
||||
* @returns true only when the Host committed the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
if (!response.result.ok) throw new Error(response.result.error.message)
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (generation === this.generation) {
|
||||
this.store.update((state) => {
|
||||
state.status = 'error'
|
||||
state.acknowledged = false
|
||||
state.error = messageOf(error)
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after the welcome step has begun reading durable state.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
@@ -1,4 +1,31 @@
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the general settings plugin. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
} from './onboarding-copy.ts'
|
||||
|
||||
export {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from './onboarding-copy.ts'
|
||||
|
||||
interface OnboardingSettings {
|
||||
welcomeNoticeVersion?: string
|
||||
}
|
||||
|
||||
const OnboardingSettingsSchema: z<OnboardingSettings> = z.object({
|
||||
[WELCOME_NOTICE_ACK_FIELD]: z.string(),
|
||||
})
|
||||
|
||||
/** Register the durable GUI-onboarding section when a settings provider exists. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
OnboardingSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a copy-owning registrant contributing chrome content
|
||||
* and the General section into shell-declared slots — it emits no cordis
|
||||
* events and owns no cross-plugin mutable relation; slot conflicts already
|
||||
* fail loud in the slot core at load time.
|
||||
* No runtime invariant: the settings seam validates and publishes the durable
|
||||
* welcome section, while slot conflicts fail loud in the slot core; this
|
||||
* package owns no additional event/data relationship between those systems.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
37
packages/client/ui-settings-general/src/onboarding-copy.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Durable settings namespace for product-wide GUI onboarding facts. */
|
||||
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
|
||||
|
||||
/** Field storing the last welcome notice version the user acknowledged. */
|
||||
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
|
||||
|
||||
/**
|
||||
* Bump only when the notice changes materially and every user should see it
|
||||
* again. The acknowledgement is compared for exact equality.
|
||||
*/
|
||||
export const WELCOME_NOTICE_VERSION = '2026-07-30.5'
|
||||
|
||||
/** The complete editable welcome notice in both supported GUI locales. */
|
||||
export const WELCOME_NOTICE_COPY = {
|
||||
zh: {
|
||||
title: '内测声明',
|
||||
paragraphs: [
|
||||
'感谢您愿意拨冗试用 DeepSeek Harness。',
|
||||
'目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。',
|
||||
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
|
||||
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
|
||||
],
|
||||
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
|
||||
continueLabel: '继续',
|
||||
},
|
||||
en: {
|
||||
title: 'Internal Testing Notice',
|
||||
paragraphs: [
|
||||
'Thank you for taking the time to try DeepSeek Harness.',
|
||||
'This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.',
|
||||
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.',
|
||||
'We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in the company WeChat group. Every piece of feedback helps us refine it.',
|
||||
],
|
||||
feedbackEmphasis: 'If you have any feedback or suggestions, please leave us a message in the company WeChat group',
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
} as const
|
||||
@@ -7,13 +7,17 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
|
||||
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
|
||||
import { GeneralSection } from '../src/client/GeneralSection.tsx'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
/** The four seats this plugin fills (slot name → expected component). */
|
||||
/** The five seats this plugin fills (slot name → expected component). */
|
||||
const SEATS = [
|
||||
['settings.trigger', TriggerContent],
|
||||
['settings.header', HeaderContent],
|
||||
['settings.close', CloseLabel],
|
||||
['settings.section', GeneralSection],
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
] as const
|
||||
|
||||
async function bench() {
|
||||
@@ -21,7 +25,25 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
|
||||
const settingsDescribe = vi.fn(() => Promise.resolve({
|
||||
rpcId: 'settings-general' as never,
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: {},
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}))
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
|
||||
}
|
||||
|
||||
/** Declare the shell's four child slots the way ui-settings' entry does. */
|
||||
@@ -34,6 +56,7 @@ function declare(slots: SlotsService): () => void {
|
||||
'settings.header': { kind: 'single', scope: 'root' },
|
||||
'settings.close': { kind: 'single', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
'settings.onboarding': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never,
|
||||
() => null,
|
||||
@@ -46,10 +69,10 @@ function generalEntry(slots: SlotsService) {
|
||||
|
||||
describe('ui-settings-general apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('fills all four seats for declarations before or after apply', async () => {
|
||||
it('fills all five seats for declarations before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots)
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -62,6 +85,8 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
|
||||
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
|
||||
expect(before.slots.entries('settings.general.item')).toEqual([])
|
||||
const welcome = before.slots.entries('settings.onboarding')[0]!
|
||||
expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
|
||||
// Copy rides the standard locale seat: every seat declares the namespace.
|
||||
for (const [name] of SEATS) {
|
||||
expect(before.slots.entries(name)[0]!.locale).toBe('settings')
|
||||
@@ -113,6 +138,22 @@ describe('ui-settings-general apply', () => {
|
||||
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
|
||||
})
|
||||
|
||||
it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
await controller.load()
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', 'unrelated')
|
||||
expect(b.settingsDescribe).toHaveBeenCalledOnce()
|
||||
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
|
||||
b.ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
29
packages/client/ui-settings-general/tests/host.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-settings-general host', () => {
|
||||
it('registers and disposes the durable onboarding namespace with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(
|
||||
settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,4 @@ describe('invariant companion', () => {
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
|
||||
import type { WelcomeNoticeProps } from '../src/client/WelcomeNotice.tsx'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function response<T>(value: T) {
|
||||
return { rpcId: 'welcome-rpc' as never, result: { ok: true as const, value } }
|
||||
}
|
||||
|
||||
function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Promise.resolve(response({}))) {
|
||||
const mutate = vi.fn(mutateImpl)
|
||||
const api = {
|
||||
settings: {
|
||||
describe: () => Promise.resolve(response({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}],
|
||||
})),
|
||||
mutate,
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
const complete = vi.fn()
|
||||
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
|
||||
const props: WelcomeNoticeProps = {
|
||||
stepId: 'welcome-notice',
|
||||
complete,
|
||||
openSection: vi.fn(),
|
||||
useSessions: unusedHook,
|
||||
useWorkspaces: unusedHook,
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
t: key => key in zh ? zh[key as keyof typeof zh] : key,
|
||||
}
|
||||
return { ...render(<WelcomeNotice {...props} />), complete, controller, mutate }
|
||||
}
|
||||
|
||||
describe('WelcomeNotice', () => {
|
||||
it('renders the owner copy with one primary action and no dismissal control', async () => {
|
||||
const h = mount()
|
||||
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
|
||||
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
|
||||
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
|
||||
const buttons = page.querySelectorAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
|
||||
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('region')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('completes only after the acknowledgement write commits', async () => {
|
||||
const h = mount()
|
||||
await screen.findByRole('region')
|
||||
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(h.mutate).toHaveBeenCalledOnce()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips itself when this exact version was already acknowledged', async () => {
|
||||
const h = mount(WELCOME_NOTICE_VERSION)
|
||||
await act(async () => { await h.controller.load() })
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
expect(h.complete).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the sole action disabled while saving and reports a refused write', async () => {
|
||||
let resolveWrite!: (value: unknown) => void
|
||||
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
|
||||
const h = mount(undefined, () => write)
|
||||
await screen.findByRole('region')
|
||||
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
|
||||
fireEvent.click(action)
|
||||
expect(action.disabled).toBe(true)
|
||||
resolveWrite({
|
||||
rpcId: 'welcome-refused' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'read only', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
})
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('暂时无法保存确认状态,请重试。')
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
166
packages/client/ui-settings-general/tests/welcome-store.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
||||
import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '../src/onboarding-copy.ts'
|
||||
|
||||
let rpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
function namespace(version?: string) {
|
||||
return {
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
schema: {},
|
||||
value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges only the exact current copy version', async () => {
|
||||
for (const [version, acknowledged] of [
|
||||
[undefined, false],
|
||||
['older-copy', false],
|
||||
[WELCOME_NOTICE_VERSION, true],
|
||||
] as const) {
|
||||
const api = {
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
|
||||
},
|
||||
}
|
||||
const controller = new WelcomeNoticeStore(api as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the owner version through one idempotent path mutation', async () => {
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
||||
const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
||||
})
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
})
|
||||
|
||||
it('keeps the notice pending when loading or persistence fails', async () => {
|
||||
const load = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.reject(new Error('offline')) },
|
||||
} as never)
|
||||
await load.load()
|
||||
expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
||||
|
||||
const nonError = new WelcomeNoticeStore({
|
||||
// Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
||||
settings: { describe: () => Promise.reject('offline string') },
|
||||
} as never)
|
||||
await nonError.load()
|
||||
expect(nonError.store.getSnapshot().error).toBe('offline string')
|
||||
})
|
||||
|
||||
it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
||||
for (const describe of [
|
||||
() => Promise.resolve({
|
||||
rpcId: 'failed' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
||||
}),
|
||||
() => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
||||
]) {
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
}
|
||||
|
||||
for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { describe: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{ ...namespace(), value }],
|
||||
})) },
|
||||
} as never)
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
||||
}
|
||||
|
||||
const save = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => Promise.resolve({
|
||||
rpcId: 'failed-save' as never,
|
||||
result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
||||
}) },
|
||||
} as never)
|
||||
await expect(save.acknowledge()).resolves.toBe(false)
|
||||
expect(save.store.getSnapshot().error).toBe('denied')
|
||||
})
|
||||
|
||||
it('lets the latest load win over stale success and failure', async () => {
|
||||
const first = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
||||
const stale = controller.load()
|
||||
await controller.load()
|
||||
first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
||||
await stale
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
|
||||
const failed = deferred<ReturnType<typeof ok>>()
|
||||
describe
|
||||
.mockImplementationOnce(() => failed.promise)
|
||||
.mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
|
||||
const staleFailure = controller.load()
|
||||
await controller.load()
|
||||
failed.reject('stale failure')
|
||||
await staleFailure
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
||||
})
|
||||
|
||||
it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
||||
const write = deferred<ReturnType<typeof ok>>()
|
||||
const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
||||
const controller = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => write.promise, describe },
|
||||
} as never)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
const staleWrite = controller.acknowledge()
|
||||
await controller.load()
|
||||
write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
||||
await expect(staleWrite).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
||||
refreshWelcomeIfLoaded(controller)
|
||||
await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const failedWrite = deferred<ReturnType<typeof ok>>()
|
||||
const staleFailure = new WelcomeNoticeStore({
|
||||
settings: { mutate: () => failedWrite.promise, describe },
|
||||
} as never)
|
||||
const pending = staleFailure.acknowledge()
|
||||
await staleFailure.load()
|
||||
failedWrite.reject('late failure')
|
||||
await expect(pending).resolves.toBe(false)
|
||||
expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
@@ -23,9 +26,15 @@
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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-settings/README.md
|
||||
README.md: eaa588489bbb6369a9fe073f0a9a37efb3af7d9f
|
||||
README.zh.md: 4330ea90e487270c8da31dace3d531d94f61b8de
|
||||
README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
|
||||
README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
|
||||
|
||||
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
|
||||
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
|
||||
|
||||
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
|
||||
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
|
||||
"description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -43,7 +43,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
@@ -52,9 +53,11 @@
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -209,3 +209,33 @@
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* First-run stage: keep the product top bar visible, then let onboarding own
|
||||
the complete workspace instead of presenting another settings modal. */
|
||||
.onboardingOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Mask */
|
||||
.onboardingMask {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
top: 80px;
|
||||
bottom: 0px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
/* Mask-blur */
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.onboardingStage {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
* names resolve to that content (trigger: its own text; dialog:
|
||||
* aria-labelledby the title node; close: visually-hidden slot text). Modal
|
||||
* open state and the active section id are component-local viewing state;
|
||||
* the onboarding slot receives the sessions-derived empty-Hero fact and a
|
||||
* private callback that opens one registered section.
|
||||
* the onboarding coordinator mounts exactly one ordered registrant while the
|
||||
* sessions-derived empty-Hero fact is active.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
@@ -95,9 +96,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, useSections, useSessions, renderSlot } = props
|
||||
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setActiveId(undefined)
|
||||
@@ -111,9 +113,33 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
const rows = useSections(s => s)
|
||||
const onboardingSteps = useOnboardingSteps(s => s)
|
||||
const onboardingActive = useSessions(state =>
|
||||
state.phase === 'ready'
|
||||
&& (state.current === undefined || state.byId[state.current]?.blank === true))
|
||||
const onboardingStep = onboardingActive
|
||||
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) return
|
||||
setCompletedOnboarding(new Set())
|
||||
}, [onboardingActive])
|
||||
|
||||
const completeOnboardingStep = useCallback((id: string) => {
|
||||
setCompletedOnboarding((previous) => {
|
||||
if (previous.has(id)) return previous
|
||||
return new Set([...previous, id])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingStep === undefined) return
|
||||
const appRoot = document.getElementById('root')
|
||||
if (appRoot === null) return
|
||||
appRoot.inert = true
|
||||
return () => { appRoot.inert = false }
|
||||
}, [onboardingStep])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -135,7 +161,18 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
|
||||
{onboardingStep !== undefined && createPortal((
|
||||
<div className={css.onboardingOverlay} role="presentation">
|
||||
<div className={css.onboardingMask} aria-hidden="true" />
|
||||
<div className={css.onboardingStage}>
|
||||
{renderSlot('settings.onboarding', {
|
||||
stepId: onboardingStep.id,
|
||||
complete: () => { completeOnboardingStep(onboardingStep.id) },
|
||||
openSection,
|
||||
}, { only: onboardingStep.id })}
|
||||
</div>
|
||||
</div>
|
||||
), document.body)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* Root-scoped onboarding overlays contributed by settings features. The
|
||||
* shell supplies whether the current navigation state is the empty Hero
|
||||
* and a private callback that opens one settings section; registrants own
|
||||
* readiness, copy, and dialog behavior.
|
||||
* Root-scoped onboarding steps contributed by settings features. The
|
||||
* shell mounts one ordered step at a time; the active registrant either
|
||||
* completes itself or keeps ownership until the user completes its sole
|
||||
* path. Registrants own readiness, copy, and dialog behavior.
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
}
|
||||
@@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of a settings-backed onboarding overlay. */
|
||||
/** Owner share of the currently active settings-backed onboarding step. */
|
||||
export interface SettingsOnboardingOwnerProps {
|
||||
/** Whether the current UI is in its empty Hero/onboarding state. */
|
||||
active: boolean
|
||||
/** Stable id of the step currently selected by the coordinator. */
|
||||
stepId: string
|
||||
/** Complete or skip this step and transfer ownership to the next entry. */
|
||||
complete: () => void
|
||||
/** Open the settings panel directly on one registered section. */
|
||||
openSection: (id: string) => void
|
||||
}
|
||||
@@ -94,6 +96,12 @@ export interface SettingsSectionRow {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One ordered onboarding step projected from a slot registration. */
|
||||
export interface SettingsOnboardingStep {
|
||||
id: string
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
@@ -103,6 +111,8 @@ export type SettingsRootInjected = {
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
/** settings.onboarding ledger projected into coordinator order. */
|
||||
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Settings shell plugin, browser half. A pure composition face: occupies the
|
||||
* sidebar-owned `sidebar.settings` hole with the trigger chrome + modal
|
||||
* panel, declares its chrome, section, and onboarding slots, and projects the
|
||||
* section ledger into panel navigation. The shell ships no copy and reads no
|
||||
* locale state — all text arrives from registrants (ui-settings-general owns
|
||||
* the chrome and General content; features own their rows, sections, and
|
||||
* onboarding overlays). Export discipline: packages/client/AGENTS.md.
|
||||
* section ledger into panel navigation. The shell ships no copy; it reads the
|
||||
* optional locale revision only to resolve registrant-owned nav-label thunks.
|
||||
* ui-settings-general owns the chrome and General content; features own their
|
||||
* rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the ctx.locale Context merge for the optional ctx.get('locale')
|
||||
@@ -13,12 +13,15 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// copy of its own and takes no hard locale dependency).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
|
||||
SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -42,6 +45,8 @@ export function apply(ctx: ClientContext): void {
|
||||
let rowsVersion = -1
|
||||
let rowsRevision = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
let onboardingVersion = -1
|
||||
let onboardingSteps: readonly SettingsOnboardingStep[] = []
|
||||
const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
@@ -72,6 +77,23 @@ export function apply(ctx: ClientContext): void {
|
||||
}
|
||||
},
|
||||
},
|
||||
onboardingSteps: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.onboarding')
|
||||
if (version !== onboardingVersion) {
|
||||
onboardingVersion = version
|
||||
onboardingSteps = ctx.slots.entries('settings.onboarding')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return onboardingSteps
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
|
||||
@@ -83,6 +83,29 @@ describe('ui-settings apply', () => {
|
||||
off()
|
||||
})
|
||||
|
||||
it('projects onboarding entries into stable coordinator order', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const { onboardingSteps } = injectedOf(b.slots).hooks
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
||||
const steps = onboardingSteps.getSnapshot()
|
||||
expect(steps).toEqual([
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
{ id: 'default-order', order: 0 },
|
||||
])
|
||||
expect(onboardingSteps.getSnapshot()).toBe(steps)
|
||||
const listener = vi.fn()
|
||||
const off = onboardingSteps.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
type Row = { id: string; order: number; label: string }
|
||||
type Step = { id: string; order: number }
|
||||
|
||||
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
|
||||
const SEAT_CONTENT: Record<string, string> = {
|
||||
@@ -23,7 +24,11 @@ function mount({
|
||||
{ id: 'general', order: 0, label: 'General' },
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
|
||||
steps = [
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
@@ -46,6 +51,7 @@ function mount({
|
||||
useSessions,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
useOnboardingSteps: select => select(steps),
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -164,20 +170,41 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(screen.queryByTestId('section-general')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
|
||||
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
|
||||
const { renderSlot } = mount()
|
||||
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(onboardingCall?.[1]).toMatchObject({ active: true })
|
||||
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
|
||||
expect(first?.[2]).toEqual({ only: 'welcome' })
|
||||
act(() => {
|
||||
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
(first?.[1] as { complete: () => void }).complete()
|
||||
;(first?.[1] as { complete: () => void }).complete()
|
||||
})
|
||||
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
|
||||
const second = onboardingCalls.at(-1)
|
||||
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
|
||||
expect(second?.[2]).toEqual({ only: 'credential' })
|
||||
|
||||
act(() => {
|
||||
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByTestId('section-models')).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.find(call => call[0] === 'settings.onboarding')
|
||||
expect(active?.[1]).toMatchObject({ active: false })
|
||||
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.filter(call => call[0] === 'settings.onboarding')
|
||||
expect(inactive).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('makes the underlying application inert while onboarding owns the viewport', () => {
|
||||
const appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.append(appRoot)
|
||||
const { view } = mount()
|
||||
expect(appRoot.inert).toBe(true)
|
||||
view.unmount()
|
||||
expect(appRoot.inert).toBe(false)
|
||||
appRoot.remove()
|
||||
})
|
||||
|
||||
it('falls back to the first row when the active entry unregisters', () => {
|
||||
|
||||
@@ -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/compact/compact-basic/README.md
|
||||
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
|
||||
README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158
|
||||
README.md: b35e5dc110e908047338054337b309a77b7e0f68
|
||||
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
|
||||
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
|
||||
|
||||
Rules:
|
||||
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
|
||||
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
|
||||
- Capture user feedback and explicit instructions faithfully, especially corrections.
|
||||
- Do NOT mention this summarization request or that the context was compacted.
|
||||
- Output only the checkpoint text: do not call any tool or take any other action.
|
||||
|
||||
@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization request or that the context was compacted.',
|
||||
'- Output only the checkpoint text: do not call any tool or take any other action.',
|
||||
|
||||
@@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => {
|
||||
expect(messages[0]).toEqual(prefix)
|
||||
const last = messages.at(-1)?.content[0]
|
||||
const lastText = last?.type === 'text' ? last.text : ''
|
||||
expect(lastText).toContain('Condense the conversation ABOVE')
|
||||
expect(lastText).toContain('Write concise English engineering prose.')
|
||||
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(lastText).toContain('## Primary Request and Intent')
|
||||
})
|
||||
|
||||
|
||||
@@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('') ?? ''
|
||||
expect(instruction).toContain('Write concise English engineering prose.')
|
||||
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
|
||||
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
|
||||
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
|
||||
expect(retry).toContain('RECOVERY CHECKPOINT')
|
||||
|
||||
@@ -2287,6 +2287,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ScopeKey',
|
||||
declaration: 'export type ScopeKey = object;',
|
||||
},
|
||||
{
|
||||
name: 'SearchFileMatches',
|
||||
declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchLineMatch',
|
||||
declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchMatchesResultView',
|
||||
declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n shape: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchPathsResultView',
|
||||
declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n shape: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SearchResultView',
|
||||
declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;',
|
||||
},
|
||||
{
|
||||
name: 'SendOptions',
|
||||
declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}',
|
||||
@@ -2857,7 +2877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolResultView',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;',
|
||||
},
|
||||
{
|
||||
name: 'ToolRunContext',
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: dcce455f9551318f3871e3df84c29789078fef7c
|
||||
README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99
|
||||
README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda
|
||||
README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a
|
||||
|
||||
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
|
||||
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
|
||||
|
||||
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
|
||||
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user