diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
index 1d7c7a29bb..3f8444bcfa 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
@@ -24,8 +24,8 @@
en: ec3703b7e7c6b23d
zh: 325755e6e90092da
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
- en: f734fdc57a6546c9
- zh: 027c28cbc584a8c2
+ en: 6dba3b9a51c28b6d
+ zh: 58a93f6ba2d269ea
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/tool-consumer-behavior:
en: 62127883453dd79c
zh: 01503a8177a0ddab
@@ -55,19 +55,16 @@
zh: 64032ea6ab61b836
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/mirror-claude-code-s-url-prompt-webfetch-shape:
en: 56a080de39e96056
- zh: ae4d070aa7657503
-/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/validate-dns-and-then-call-an-ordinary-fetch:
- en: aa6c1e7e6374a875
- zh: 045c8af6cd44c3e1
-/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/block-private-looking-hostname-strings-without-pinning-resolved-addresses:
- en: 3d906f3056733479
- zh: d652723eb5372007
+ zh: 937336a73de70518
+/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/restrict-fetch-to-public-addresses:
+ en: af3014c0de7ff66f
+ zh: 509040c9183327fe
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/require-per-call-approval-before-public-fetches:
- en: f8d1fc8e9c9659cf
- zh: da5792520e35cba0
+ en: 3ea94ffb142d144a
+ zh: 89645f68412f6769
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
- en: 12fd20ba54818215
- zh: c1ca000b13666701
+ en: 9774448fd6b21c41
+ zh: 8411c590ad70d5c4
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
en: 48b7b8e7310cd56b
zh: d406cd6a8a649e58
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
index 135447cb15..91e212ad9f 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
@@ -202,7 +202,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis
## Fetch request and result schema
-The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services.
+The `web_fetch` implementation is an anonymous HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, or internal auth tokens. It does not restrict destination addresses.
The seam request stays smaller than OpenCode's model-facing tool:
@@ -238,14 +238,12 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir
The fetch provider's resource controls:
- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
-- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected.
-- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation.
- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
- Abort signals propagate through network fetches and expensive decoding.
-- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
+- Only same-origin redirects are followed automatically; each followed hop repeats URL validation. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
- Requests carry an explicit product user agent rather than silently impersonating a browser.
-The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy.
+Requests use the process-wide dispatcher, so the installed outbound proxy policy and the system resolver decide the connection. Loopback, private-network, and cloud metadata destinations are reachable.
## Tool consumer behavior
@@ -313,17 +311,13 @@ Rejected for the first version. Those providers often return extracted or summar
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
-### Validate DNS and then call an ordinary fetch
+### Restrict fetch to public addresses
-Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior.
-
-### Block private-looking hostname strings without pinning resolved addresses
-
-Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it.
+Rejected. Resolving each hostname, rejecting any non-public answer, and pinning the connection to the validated set does not confine the model, which reaches the same networks through mounted shell tools, and it refuses every page on hosts whose DNS answers with non-public addresses, such as TUN-mode proxies with fake-IP DNS. Deployments that must keep the model away from private destinations need network confinement, a `tools/pre-execute` policy, or no fetch tool.
### Require per-call approval before public fetches
-Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
+Rejected for the shipped presets. Per-call confirmation would interrupt ordinary browsing without controlling data egress reliably: a model can reach the same networks through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
## Consequences
@@ -335,7 +329,7 @@ Rejected for the shipped presets. Public-address validation blocks SSRF destinat
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
-**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
+**Fetch is a network boundary, not just a read-only tool.** `web_fetch` reaches any destination the host can, including loopback and private-network services, a model can disclose data through a URL, and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
index 736b50ebb3..27b7777f23 100644
--- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
@@ -202,7 +202,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`
## Fetch 请求与结果 schema
-`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。
+`web_fetch` 的实现是一个匿名 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证或内部认证令牌。它不限制目的地址。
seam 请求比 OpenCode 的面向模型工具更小:
@@ -238,14 +238,12 @@ export type WebFetchBody =
fetch 提供方的资源控制:
- 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。
-- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。
-- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
- Abort 信号传播到网络获取和高开销解码。
-- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
+- 仅自动跟随同源重定向;每个跟随的跳转都会重复 URL 校验。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。
-只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
+请求使用进程级 dispatcher,因此由已安装的出站代理策略与系统解析器决定连接。loopback、内网与云 metadata 目的地址都可访问。
## 工具消费方行为
@@ -313,17 +311,14 @@ fetch 提供方的资源控制:
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。
-### 验证 DNS 后调用普通 fetch
+
+### 只允许抓取公开地址
-否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。
-
-### 只阻断看起来像私网的 hostname 字符串,不固定解析地址
-
-否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。
+否决。解析每个 hostname、拒绝任何非公开解析结果并把连接固定到已验证地址,并不能约束模型,因为模型可以通过已挂载的 shell 工具访问相同网络;同时在 DNS 返回非公开地址的主机上(例如使用 fake-IP DNS 的 TUN 模式代理),这会拒绝所有页面。需要阻止模型访问私有目的地址的部署应使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
### 在公开抓取前要求逐次审批
-已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
+已交付的 preset 不采用这一方案。逐次确认会打断普通浏览,却不能可靠控制数据出站:模型可以通过已挂载的 shell 工具访问相同网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
## 后果
@@ -335,7 +330,7 @@ fetch 提供方的资源控制:
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
-**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
+**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 可以访问主机能访问的任何目的地址,包括 loopback 与内网服务;模型可以通过 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml
index 18f65a56f9..1169daf37a 100644
--- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml
@@ -9,14 +9,14 @@
en: 6379f0a2522f4c46
zh: c45ffeb5089be274
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision:
- en: 92526818ff4870c2
- zh: 8c54ff3f4941d5ad
+ en: 6435c336ab53ca0f
+ zh: 57070799a7061f85
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered:
en: df6c078f239a11f5
zh: 914da26836aa373b
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences:
- en: 6a4acbab915f1bd1
- zh: 2c8ffd62ba74ff96
+ en: ff1898a64e5db79c
+ zh: 0a77d02385fb3506
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/testing:
- en: 0d7eaa666c94a5cd
- zh: 210fa098f154d137
+ en: fddb0be6abbd809e
+ zh: 88843f0f3ea60555
diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
index 7d9b909678..fb2d4ae3a6 100644
--- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
+++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
@@ -36,7 +36,7 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
**Rejection is quiet, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead.
-**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently.
+**`web_fetch` uses the installed dispatcher.** The provider sends every request through the process-wide dispatcher, so a proxied hop goes through the proxy, which performs the origin's DNS, and a hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, connects directly. The provider does not restrict destination addresses ([web capability seam](2026-06-24-web-capability-seam.md#restrict-fetch-to-public-addresses)).
The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop.
@@ -54,7 +54,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
**Every call site carries an egress test.** Each owning package's `egress.spec.ts` drives its actual transport through a fake proxy and checks the observed route. These tests cover search backends, pi-ai discovery, MCP over HTTP, child Node processes, worker threads and telemetry's direct-route exception. They detect dependency transport changes that a static call-site check cannot observe.
-**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
+**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and a call site that genuinely owns its transport says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
## Alternatives considered
@@ -78,8 +78,6 @@ A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied
Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness.
-`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters.
-
Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field.
The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first.
@@ -88,7 +86,7 @@ The suite is hermetic against the developer's own environment: every Vitest conf
`packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce.
-`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path.
+`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts that a proxied hop reaches the proxy, a bypassed hop connects directly, and the cross-origin redirect refusal survives on the proxied path.
`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree.
diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md
index f24e811af7..bd48a6e6b5 100644
--- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md
@@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。
-**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。
+**`web_fetch` 使用已安装的 dispatcher。** 该提供方的每个请求都经过进程级 dispatcher,因此代理转发的一跳经过代理,由代理执行 origin 的 DNS;被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,则直接连接。该提供方不限制目的地址([web capability seam](2026-06-24-web-capability-seam.zh.md#restrict-fetch-to-public-addresses))。
URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
@@ -54,7 +54,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
**每个出网点都配有出网测试。** 各所属包中的 `egress.spec.ts` 通过假代理驱动实际传输,并检查观察到的路由。这些测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、子 Node 进程、worker 线程和遥测的直连例外。它们可发现静态调用点检查无法观察到的依赖传输变化。
-**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
+**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;确实自有传输的调用点用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
## Alternatives considered
@@ -78,8 +78,6 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。
-`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。
-
userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。
测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。
@@ -88,7 +86,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。
-`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。
+`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言代理转发的一跳抵达代理、被绕过的一跳直接连接,且跨域重定向拒绝在代理路径上依然成立。
`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml
index d3002b42b6..dea1cb8a9f 100644
--- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml
@@ -15,11 +15,11 @@
en: ec653b59d62ed95f
zh: 0d675616059930ba
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
- en: a95618f172ef63ee
- zh: 306556be09c56bdf
+ en: ad7b3e4fbe528cf2
+ zh: 8ab8779bfdaaa4fa
/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership:
- en: 8051417b08739ed0
- zh: c11d35a346014436
+ en: 7566e400f9ab3a51
+ zh: 118b2e84d0030480
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
en: d5a8e36b545f1c04
zh: d741fd0789401c62
@@ -33,5 +33,5 @@
en: a55da02f4d3c06c0
zh: 3c1a624f03f34f28
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
- en: 41f9a331c7aca397
- zh: 17a0fd53e133c2f5
+ en: 4c84e7bd035b52a7
+ zh: 4e5687bda0a47706
diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md
index 1df773558d..4369faee79 100644
--- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md
+++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md
@@ -24,7 +24,7 @@ The guide specifies API-key Bearer authentication, and requests send only the do
Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful.
-The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
+The provider preserves service order and maps URL, title, `snippet`, `summary`, and nullable `date` into shared sources. Following the guide, the tool gives the model each source's `summary` and keeps the short `snippet` for the search card and its replay metadata. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. `dsh-tool-web` therefore collapses summary whitespace and caps each summary at `searchSummaryMaxChars`, 800 characters by default, the upper end of the guide's typical range; one call's summaries stay within the source cap times that cap. The summary rides in the tool output value and rendered text, which the Session already records. The provider does not consume full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data.
@@ -32,7 +32,7 @@ Direct retrieval emits no auxiliary model-request event because Harness makes no
Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged.
-The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
+The HTTP provider retains its own URL validation and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
### Default migration
@@ -78,7 +78,6 @@ Provider tests cover request mapping, empty results, malformed responses, indepe
The following capabilities remain outside the implemented integration:
-- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16.
- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language.
- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources.
- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes.
diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md
index 997a7d5509..0b736cb445 100644
--- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md
+++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md
@@ -24,7 +24,7 @@ Status: implemented
直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。
-提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
+提供方保留服务端顺序,将 URL、标题、`snippet`、`summary` 和可为 null 的 `date` 映射为共享来源。按照指南,工具把每个来源的 `summary` 提供给模型,短的 `snippet` 留给搜索卡片及其回放元数据。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。因此 `dsh-tool-web` 合并摘要中的空白,并将每条摘要限制在 `searchSummaryMaxChars` 以内,默认 800 个字符,即指南典型范围的上限;一次调用的摘要总长不超过来源上限乘以该上限。摘要随工具输出值和渲染文本一起进入 Session 记录。提供方不使用完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。
@@ -32,7 +32,7 @@ Status: implemented
Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。
-HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
+HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
### 默认值迁移
@@ -78,7 +78,6 @@ Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一
以下能力尚未包含在已实现的接入中:
-- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。
- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。
- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。
- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。
diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml
index 3144f47b3a..d7cbbd9ac6 100644
--- a/apps/cli/reference/README.i18n.yaml
+++ b/apps/cli/reference/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 850434e8ca162221
zh: 4893613f97a21eb8
/dsh-cli-behavior-reference/shared-deployment-behavior:
- en: 6a7abc9339e8f37f
- zh: 133adb834c8e195d
+ en: f40edcf903a51f3f
+ zh: 8a9a915451047c8b
/dsh-cli-behavior-reference/source-execution:
en: 05c2b23790f7b296
zh: 51f809020fb230a1
diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md
index 58ffe96eb8..98b7197a92 100644
--- a/apps/cli/reference/README.md
+++ b/apps/cli/reference/README.md
@@ -134,7 +134,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission
## Shared deployment behavior
-The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
+The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md
index 85312166bc..6441309f1c 100644
--- a/apps/cli/reference/README.zh.md
+++ b/apps/cli/reference/README.zh.md
@@ -136,7 +136,7 @@ dsh web --help
## 共享部署行为
-基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
+基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts
index 0954aa180a..f8afb08a67 100644
--- a/apps/web/tests/cot-translation-paid.e2e.ts
+++ b/apps/web/tests/cot-translation-paid.e2e.ts
@@ -195,8 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
- // The bundle's README is its detail document, shown between its components and its source.
- await panel.locator('[data-detail-document] h1').waitFor()
+ // The bundle's detail document is shown between its components and its source.
+ await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts
index 8419b55b79..cf10562c90 100644
--- a/apps/web/tests/cot-translation.e2e.ts
+++ b/apps/web/tests/cot-translation.e2e.ts
@@ -212,8 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
- // The bundle's README is its detail document, shown between its components and its source.
- await panel.locator('[data-detail-document] h1').waitFor()
+ // The bundle's detail document is shown between its components and its source.
+ await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts
index 7a35661fe4..9cc9cf1f16 100644
--- a/apps/web/tests/cua-optin.e2e.ts
+++ b/apps/web/tests/cua-optin.e2e.ts
@@ -45,7 +45,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
const before = await readFile(settingsPath, 'utf8')
const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+ const plugins = page.locator('[data-plugin-panel]')
+ await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
+ expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
+ await plugins.getByRole('button', { name: '更多', exact: true }).click()
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
+ expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await card.textContent()).toContain('请切换到 PTC 模式')
diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
index 3e4ab96099..f50238cc81 100644
--- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
+++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- - paragraph: English | 中文
- - heading "Summary" [level=2]
- - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- - heading "Table of Contents" [level=2]
+ - paragraph:
+ - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
+ - paragraph:
+ - text: 展开
+ - strong: 思考
+ - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
+ - heading "使用方法" [level=2]
- list:
- - listitem: Use this package
- - listitem: Understand the implementation
- - listitem: Further Exploration
- - listitem: Model Experience
- - listitem: Known Limitations and Deferred Work
- - listitem: Dev Note
- - separator
- - paragraph
- - heading "Use this package" [level=2]
- - paragraph:
- - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- - code: auto
- - text: follows the interface language; an explicit language code such as
- - code: zh
- - text: ","
- - code: en
- - text: ", or"
- - code: ja
- - text: overrides it. Save applies the preferences.
- - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- - paragraph:
- - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- - code: deepseek-flash
- - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- - separator
- - paragraph
- - heading "Understand the implementation" [level=2]
- - paragraph:
- - text: The bundle patch inserts
- - code: translator
- - text: and
- - code: cot-translation
- - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- - separator
- - paragraph
- - heading "Further Exploration" [level=2]
- - paragraph: Text translation subsystem · Translator service · GUI consumer
- - separator
- - paragraph
- - heading "Model Experience" [level=2]
- - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- - heading "KV Cache effect" [level=4]
- - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- - heading "Known Limitations and Deferred Work" [level=2]
- - paragraph
+ - listitem:
+ - text: 展开
+ - strong: 思考
+ - text: 行。点击
+ - strong: 查看原文
+ - text: 显示原文,点击
+ - strong: 查看译文
+ - text: 切换回译文。
+ - listitem:
+ - text: 在此插件的页面中选择
+ - strong: 翻译服务
+ - text: 和
+ - strong: 目标语言
+ - text: ,然后点击
+ - strong: 保存
+ - text: 。
+ - code: auto
+ - text: 表示跟随界面语言;也可以填写
+ - code: zh
+ - text: 、
+ - code: en
+ - text: 、
+ - code: ja
+ - text: 等语言代码。
+ - listitem:
+ - text: 翻译失败时,原文保持显示,并提供
+ - strong: 重试
+ - text: 。
+ - heading "注意事项" [level=2]
- list:
- - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- - separator
- - paragraph
- - heading "Dev Note" [level=3]
- - paragraph: None.
+ - listitem: 你展开的思考内容可能包含你的代码和对话细节。
+ - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
+ - listitem:
+ - text: 如果已配置 DeepSeek 账号或官方 API,还会出现
+ - strong: DeepSeek Flash · 账号(付费)
+ - text: 和
+ - strong: DeepSeek Flash · 官方 API(付费)
+ - text: 。此时每段没有已保存译文的内容都是一次付费请求。
+ - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
+ - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
+ - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md
index b2c7e3c3d7..223bcb3571 100644
--- a/apps/web/tests/expected/cot-translation/preferences.expected.md
+++ b/apps/web/tests/expected/cot-translation/preferences.expected.md
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- - paragraph: English | 中文
- - heading "Summary" [level=2]
- - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- - heading "Table of Contents" [level=2]
+ - paragraph:
+ - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
+ - paragraph:
+ - text: 展开
+ - strong: 思考
+ - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
+ - heading "使用方法" [level=2]
- list:
- - listitem: Use this package
- - listitem: Understand the implementation
- - listitem: Further Exploration
- - listitem: Model Experience
- - listitem: Known Limitations and Deferred Work
- - listitem: Dev Note
- - separator
- - paragraph
- - heading "Use this package" [level=2]
- - paragraph:
- - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- - code: auto
- - text: follows the interface language; an explicit language code such as
- - code: zh
- - text: ","
- - code: en
- - text: ", or"
- - code: ja
- - text: overrides it. Save applies the preferences.
- - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- - paragraph:
- - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- - code: deepseek-flash
- - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- - separator
- - paragraph
- - heading "Understand the implementation" [level=2]
- - paragraph:
- - text: The bundle patch inserts
- - code: translator
- - text: and
- - code: cot-translation
- - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- - separator
- - paragraph
- - heading "Further Exploration" [level=2]
- - paragraph: Text translation subsystem · Translator service · GUI consumer
- - separator
- - paragraph
- - heading "Model Experience" [level=2]
- - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- - heading "KV Cache effect" [level=4]
- - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- - heading "Known Limitations and Deferred Work" [level=2]
- - paragraph
+ - listitem:
+ - text: 展开
+ - strong: 思考
+ - text: 行。点击
+ - strong: 查看原文
+ - text: 显示原文,点击
+ - strong: 查看译文
+ - text: 切换回译文。
+ - listitem:
+ - text: 在此插件的页面中选择
+ - strong: 翻译服务
+ - text: 和
+ - strong: 目标语言
+ - text: ,然后点击
+ - strong: 保存
+ - text: 。
+ - code: auto
+ - text: 表示跟随界面语言;也可以填写
+ - code: zh
+ - text: 、
+ - code: en
+ - text: 、
+ - code: ja
+ - text: 等语言代码。
+ - listitem:
+ - text: 翻译失败时,原文保持显示,并提供
+ - strong: 重试
+ - text: 。
+ - heading "注意事项" [level=2]
- list:
- - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- - separator
- - paragraph
- - heading "Dev Note" [level=3]
- - paragraph: None.
+ - listitem: 你展开的思考内容可能包含你的代码和对话细节。
+ - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
+ - listitem:
+ - text: 如果已配置 DeepSeek 账号或官方 API,还会出现
+ - strong: DeepSeek Flash · 账号(付费)
+ - text: 和
+ - strong: DeepSeek Flash · 官方 API(付费)
+ - text: 。此时每段没有已保存译文的内容都是一次付费请求。
+ - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
+ - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
+ - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md
index 01fd8e7166..2d5d1cf617 100644
--- a/apps/web/tests/expected/models-settings/model-picker.expected.md
+++ b/apps/web/tests/expected/models-settings/model-picker.expected.md
@@ -1,7 +1,7 @@
-- dialog "选择要添加的模型":
- - heading "选择要添加的模型" [level=2]
+- dialog "选择模型":
+ - heading "选择模型" [level=2]
- button "关闭"
- - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。
+ - paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。
- searchbox "搜索模型"
- button "取消全选"
- list:
@@ -15,4 +15,4 @@
- checkbox "MiniMax-M3" [checked]
- text: MiniMax-M3
- button "取消"
- - button "添加所选"
+ - button "更新列表"
diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index 27c2e72887..37d3a232b4 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- - listitem:
- - button "查看 Computer Use": Computer Use
- - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- - switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
index 9602e04da9..d00afb3ed1 100644
--- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md
+++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
+- region "Details":
+ - heading "Fixture bundle" [level=1]
+ - paragraph: English | 中文
+ - paragraph:
+ - text: Registry fixture for the Web plugin-manager tests. It declares no
+ - code: meta.detail
+ - text: ", so its page shows this README."
- heading "Components" [level=4]
- text: 3 total · 3 off
- list:
diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md
index 702150c359..067989b093 100644
--- a/apps/web/tests/expected/plugin-manager/exports.expected.md
+++ b/apps/web/tests/expected/plugin-manager/exports.expected.md
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
+- region "详细介绍":
+ - heading "Fixture bundle" [level=1]
+ - paragraph: English | 中文
+ - paragraph:
+ - text: Registry fixture for the Web plugin-manager tests. It declares no
+ - code: meta.detail
+ - text: ", so its page shows this README."
- heading "包含的组件" [level=4]
- text: 共 3 个 · 3 已停用
- list:
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index cfe553e2bb..eab9dd48e1 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- - listitem:
- - button "查看 Computer Use": Computer Use
- - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- - switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index d58ad62b03..f3dd4a7bc6 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- - listitem:
- - button "查看 Computer Use": Computer Use
- - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- - switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md
index 85d666fd3e..75dda78845 100644
--- a/apps/web/tests/expected/plugin-manager/refresh.expected.md
+++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md
@@ -6,7 +6,7 @@ Focus at 500ms:
- tooltip "刷新"
failure: pending
- button "刷新" [disabled]
-{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
+{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
failure: settled
- button "刷新"
-{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
+{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml b/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml
new file mode 100644
index 0000000000..e775a30a21
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml
@@ -0,0 +1,7 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write apps/web/tests/fixtures/plugins/fixture-bundle/README.md
+/fixture-bundle:
+ en: 987d3ae4a4d79382
+ zh: 4b7ce36da8833efe
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.md b/apps/web/tests/fixtures/plugins/fixture-bundle/README.md
new file mode 100644
index 0000000000..2fb5f33e51
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.md
@@ -0,0 +1,5 @@
+# Fixture bundle
+
+English | [中文](README.zh.md)
+
+Registry fixture for the Web plugin-manager tests. It declares no `meta.detail`, so its page shows this README.
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md b/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md
new file mode 100644
index 0000000000..486e2b7eaa
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md
@@ -0,0 +1,5 @@
+# 测试组合包
+
+[English](README.md) | 中文
+
+Web 插件管理测试用的 registry 测试组合包。它没有声明 `meta.detail`,所以它的页面显示这份 README。
diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts
index 3b9cb9879b..62b83dfafb 100644
--- a/apps/web/tests/markdown-file-links.e2e.ts
+++ b/apps/web/tests/markdown-file-links.e2e.ts
@@ -61,6 +61,35 @@ function parentPathFixture(path: string): string {
].join('\n')
}
+/** Build a settled turn whose relative link follows a directory change into a nested checkout. */
+function changedDirectoryFixture(path: string): string {
+ const session = Session.create(SessionId('changed-directory-links-source'))
+ session.append('turn/start', { turn: 1 })
+ const user = session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'Enter the worktree and link its notes.' }], source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ session.append('session/title', { title: 'Changed directory links', messageSeqs: [user.seq], source: { kind: 'fallback' } })
+ session.append('working-directory/change', { cwd: '{{cwd}}/.agents/worktrees/w' })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('assistant/message', {
+ turn: 1, step: 1, stream: [],
+ message: createAssistantMessage({
+ content: [{ type: 'text', text: `[Worktree notes](${path})` }],
+ source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+ }),
+ }, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ return [
+ JSON.stringify({
+ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
+ cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
+ }),
+ ...session.snapshotEvents().map(event => JSON.stringify(event)),
+ '',
+ ].join('\n')
+}
+
describe('web e2e: Markdown file links', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -177,4 +206,27 @@ describe('web e2e: Markdown file links', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
+ it('resolves relative links from the Session\'s current working directory', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-directory-file-links'))
+ const worktree = join(scaffold.workspaceCwd, '.agents/worktrees/w')
+ await mkdir(worktree, { recursive: true })
+ await writeFile(join(scaffold.workspaceCwd, 'notes.txt'), 'original checkout\n')
+ await writeFile(join(worktree, 'notes.txt'), 'worktree checkout\n')
+ const id = await seedSession(scaffold, changedDirectoryFixture('notes.txt'), 'changed-directory-file-links')
+ await page.reload({ waitUntil: 'load' })
+ const workspace = page.getByRole('treeitem').first()
+ await workspace.waitFor()
+ if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
+ await page.locator(`[data-row-key="session:${id}"]`).click()
+
+ await page.getByRole('button', { name: 'Worktree notes', exact: true }).click()
+ const column = page.locator('[data-rightbar-col]')
+ await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(worktree, 'notes.txt'))
+ await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('worktree checkout\n')
+ const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
+ await mkdir(artifacts, { recursive: true })
+ await page.screenshot({ path: join(artifacts, 'changed-directory-file-links.png'), animations: 'disabled' })
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ })
})
diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts
index db3cad441a..fd1a329253 100644
--- a/apps/web/tests/markdown-images.e2e.ts
+++ b/apps/web/tests/markdown-images.e2e.ts
@@ -275,7 +275,7 @@ describe('web e2e: Markdown image rendering', () => {
await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor()
expect(mediaResponses).toEqual(new Map([
[join(scaffold.workspaceCwd, 'valid.png'), 200],
- [`${scaffold.workspaceCwd}/./local-image.png`, 200],
+ [join(scaffold.workspaceCwd, 'local-image.png'), 200],
[join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200],
[join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), 200],
[join(scaffold.workspaceCwd, 'oversized.png'), 413],
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 9b5da0d115..7363d59e97 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -241,14 +241,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
- it('filters the discovered model catalog and clears hidden selections', async () => {
+ it('filters the discovered model catalog and deselects only visible candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await settingsDialog.getByText('自定义设置').click()
await settingsDialog.getByRole('button', { name: '获取可用模型' }).click()
- const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
+ const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.waitFor({ timeout: 10_000 })
const boxes = picker.getByRole('checkbox')
const count = await boxes.count()
@@ -268,12 +268,13 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await search.fill('')
await expect.poll(async () => boxes.count()).toBe(count)
const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))
- expect(restored).toEqual(Array.from({ length: count }, () => false))
+ expect(restored.filter(checked => !checked)).toHaveLength(1)
+ expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false)
await picker.getByRole('button', { name: '全选' }).waitFor()
await picker.getByRole('button', { name: '全选' }).click()
const snapshot = await captureStableAria(
page,
- '[role="dialog"][aria-label="选择要添加的模型"]',
+ '[role="dialog"][aria-label="选择模型"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE)
@@ -517,11 +518,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('button', { name: '删除模型 1' }).click()
await dialog.getByRole('button', { name: '获取可用模型' }).click()
- const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
+ const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.getByRole('button', { name: '取消全选' }).click()
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
- await picker.getByRole('button', { name: '添加所选' }).click()
+ await picker.getByRole('button', { name: '更新列表' }).click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await image.isChecked()).toBe(true)
await dialog.getByRole('button', { name: '保存', exact: true }).click()
@@ -530,7 +531,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
} finally {
await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
await edit.waitFor({ state: 'detached' })
- await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' })
+ await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index 75202aea47..effd31c913 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -27,6 +27,7 @@ const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
+const ID_SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-id-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
@@ -202,9 +203,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
{ timeout: 10_000 },
).toBe(0)
- await search.fill('WATERFALL')
const resultTree = page.getByRole('tree', { name: 'Search results' })
const result = resultTree.getByRole('treeitem')
+ // The seed log carries its id only in the header, so this mid-id,
+ // differently cased fragment can match only through local id matching.
+ await search.fill('PANES-WEB')
+ await expect.poll(() => result.count(), { timeout: 10_000 }).toBe(1)
+ await page.getByRole('status', { name: 'Searching session history…' }).waitFor({ state: 'detached', timeout: 30_000 })
+ await compareOrRefreshGolden(
+ ID_SEARCH_EXPECTED,
+ await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd),
+ MODE,
+ )
+
+ await search.fill('WATERFALL')
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
timeout: 10_000,
@@ -551,7 +563,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
- 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
+ 'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md', 'timing.expected.md',
])
})
diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts
index 9f3a50a41a..b7d6c31e9a 100644
--- a/apps/web/tests/new-session-refused.e2e.ts
+++ b/apps/web/tests/new-session-refused.e2e.ts
@@ -38,7 +38,8 @@ describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => {
expect(scaffold.ctx.sessions.list()).toEqual([])
expect(await page.getByRole('alert').count()).toBe(0)
- await page.getByRole('button', { name: '新建会话', exact: true }).first().click()
+ // The capsule: on a local build the wordmark's fallback copies the version.
+ await page.getByRole('button', { name: '新建会话', exact: true }).last().click()
const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' })
await notice.waitFor()
await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE)
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index e4ac5cb89a..4edd06e075 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -584,8 +584,27 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
- expect(await panel.getByText('实验性', { exact: true }).count())
- .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length)
+ const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
+ expect(await Promise.all(cards.map(async card => [
+ await card.getAttribute('data-plugin-package'),
+ await card.getByText('实验性', { exact: true }).count(),
+ ]))).toEqual([
+ ['@deepseek-ai/dsh-experimental-agent-team-profile', 1],
+ ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-auto-review', 1],
+ ['@deepseek-ai/dsh-experimental-inspector-profile', 1],
+ ['@deepseek-ai/dsh-cua-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-tool-worktree', 1],
+ ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-session-search', 1],
+ ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
+ ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
+ ['@deepseek-ai/dsh-subagent-codex', 0],
+ ['@deepseek-ai/dsh-subagent-claude-code', 0],
+ ])
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
@@ -727,11 +746,12 @@ describe('web e2e: plugin manager', () => {
}
expect(await preview.getAttribute('src')).toBe(chineseSource)
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
- await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
- // Detail icons have alt=""; this bundle's README detail document has no images.
+ await panel.getByRole('button', { name: '返回插件', exact: true }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
+ // A bundle without meta.detail still shows its README; this one has no images.
await panel.locator('[data-detail-document] h1').waitFor()
expect(await panel.getByRole('img').count()).toBe(0)
- await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
+ await panel.getByRole('button', { name: '返回插件', exact: true }).click()
expect(tripwire.pageErrors).toEqual([])
})
@@ -747,6 +767,9 @@ describe('web e2e: plugin manager', () => {
expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
+ // The README detail document loads asynchronously after the description.
+ const readmeHeading = panel.locator('[data-detail-document]').getByRole('heading', { name: 'Fixture bundle', exact: true })
+ await readmeHeading.waitFor()
expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
@@ -757,6 +780,7 @@ describe('web e2e: plugin manager', () => {
expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0)
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
+ await readmeHeading.waitFor()
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
}), MODE)
diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts
index 19c7a26aa2..be06ec07f0 100644
--- a/apps/web/tests/shipped-composition.e2e.ts
+++ b/apps/web/tests/shipped-composition.e2e.ts
@@ -468,8 +468,8 @@ function assertLeanChildRecord(agent: Agent): void {
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
* sandbox row confines, `mcp_*` servers spawn outside `ctx.shell`, and `ralph`
* runs unsupervised rounds whose completion is a worker self-report.
- * `web_fetch` is present because public-address enforcement and one-shot
- * approval now confine its model-selected request target. The composition
+ * `web_fetch` is present because it adds no network reach beyond the
+ * mounted shell tools. The composition
* Agent Note owns the rationale and its sources.
*/
const EXPECTED_TOOLS = [
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index 33cfdeb374..450911da2b 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -208,7 +208,7 @@ export function probeFreePort(): Promise {
*/
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise {
mkdirSync(join(root, name), { recursive: true })
- await page.getByRole('textbox', { name: 'Choose workspace' }).click()
+ await page.getByRole('button', { name: 'Add workspace', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
@@ -234,7 +234,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
*/
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise {
mkdirSync(join(root, name), { recursive: true })
- await page.getByRole('textbox', { name: '选择工作区' }).click()
+ await page.getByRole('button', { name: '添加工作区', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts
index 3ca9bae009..f831fb6324 100644
--- a/apps/web/tests/web-search-round.e2e.ts
+++ b/apps/web/tests/web-search-round.e2e.ts
@@ -50,6 +50,16 @@ function resultSnippet(queryIndex: number, ordinal: number): string {
return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
}
+/** One provider result's multi-line model-facing summary, by 1-based provider order. */
+function resultSummary(queryIndex: number, ordinal: number): string {
+ return `Snapshot search ${queryIndex + 1} summary ${ordinal}:\n\nthe model reads this longer excerpt\nfrom the Search API.`
+}
+
+/** The rendered summary: whitespace collapsed to single spaces. */
+function renderedSummary(queryIndex: number, ordinal: number): string {
+ return resultSummary(queryIndex, ordinal).replace(/\s+/gu, ' ')
+}
+
/** One provider result's publication date, by 1-based provider order (July 2026 days 01..12). */
function resultPageAge(ordinal: number): string {
return `2026-07-${String(ordinal).padStart(2, '0')}`
@@ -66,6 +76,11 @@ const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, que
publishedAt: resultPageAge(ordinal),
}))).slice(0, WEB_SEARCH_MAX_RESULTS)
+/** Model-visible source lines for the kept sources: the summary replaces the display snippet. */
+const KEPT_SOURCE_LINES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) =>
+ `- [${resultTitle(queryIndex, ordinal)}](${resultUrl(queryIndex, ordinal)}) — ${renderedSummary(queryIndex, ordinal)} (${resultPageAge(ordinal)})`,
+)).slice(0, WEB_SEARCH_MAX_RESULTS)
+
/** URLs omitted after the combined source cap is reached. */
const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
(_query, queryIndex) => resultUrl(queryIndex, ordinal),
@@ -107,6 +122,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
url: resultUrl(queryIndex, ordinal),
title: resultTitle(queryIndex, ordinal),
snippet: resultSnippet(queryIndex, ordinal),
+ summary: resultSummary(queryIndex, ordinal),
content: '', site_name: null, site_icon: null,
date: resultPageAge(ordinal),
})),
@@ -212,16 +228,18 @@ describe('web e2e: opt-in Search API web search', () => {
const rendered = message.content.filter(block => block.type === 'text').map(block => block.text).join('')
// The tool interleaves sources from both seam results before applying the
// combined cap, so each query remains represented in model-visible output.
- for (const source of KEPT_SOURCES) {
- expect(rendered).toContain(`[${source.title}](${source.url})`)
+ for (const line of KEPT_SOURCE_LINES) {
+ expect(rendered).toContain(line)
}
+ expect(rendered).not.toContain(resultSnippet(0, 1))
for (const url of DROPPED_SOURCE_URLS) {
expect(rendered).not.toContain(url)
}
expect(rendered).toContain(
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
)
- expect(searchResult.data.meta).toMatchObject({
+ // The card keeps the display snippet; the summary reaches only the model.
+ expect(searchResult.data.meta).toEqual({
sources: KEPT_SOURCES,
truncated: true,
})
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index b4a9585515..4a9e624257 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1430,7 +1430,7 @@ export interface Config {
## `@deepseek-ai/dsh-fs-local`
-- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
+- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
```ts config-catalog
/** Configuration for the local filesystem backend. */
@@ -4258,6 +4258,8 @@ export interface Config {
searchMaxResults?: number
/** Upper bound on queries accepted by one `web_search` call. */
searchMaxQueries?: number
+ /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
+ searchSummaryMaxChars?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 54fb42f3e4..f77cb50cba 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1432,7 +1432,7 @@ export interface Config {
## `@deepseek-ai/dsh-fs-local`
-- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
+- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
```ts config-catalog
/** Configuration for the local filesystem backend. */
@@ -4260,6 +4260,8 @@ export interface Config {
searchMaxResults?: number
/** Upper bound on queries accepted by one `web_search` call. */
searchMaxQueries?: number
+ /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
+ searchSummaryMaxChars?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 165a27b4b4..ea1c6a6823 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -489,7 +489,6 @@ flowchart TD
pkg_remote_mock --> pkg_typert_protocol
pkg_typert_loader --> pkg_typert_registry
pkg_skill_office --> pkg_skill
- pkg_web_fetch_http --> pkg_http_proxy
pkg_web_fetch_http --> pkg_timeout
pkg_web_fetch_http --> pkg_web
pkg_web_search_exa --> pkg_launch_environment
@@ -1600,7 +1599,7 @@ flowchart TD
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
-| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
+| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 8d69755e9d..1d66068c63 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -491,7 +491,6 @@ flowchart TD
pkg_remote_mock --> pkg_typert_protocol
pkg_typert_loader --> pkg_typert_registry
pkg_skill_office --> pkg_skill
- pkg_web_fetch_http --> pkg_http_proxy
pkg_web_fetch_http --> pkg_timeout
pkg_web_fetch_http --> pkg_web
pkg_web_search_exa --> pkg_launch_environment
@@ -1602,7 +1601,7 @@ flowchart TD
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
-| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
+| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml
index bf0be78f0f..0de78d5234 100644
--- a/docs/subsystems/web.i18n.yaml
+++ b/docs/subsystems/web.i18n.yaml
@@ -18,11 +18,11 @@
en: da78eb96d684ad47
zh: 4b726cc04c92ef13
/web-access/fetch-network-policy:
- en: 261fce9249be8b2b
- zh: bf6abf77679aca6d
+ en: 6851874be5841d90
+ zh: dc085bf3b82d77df
/web-access/errors:
en: b07c04f462b4a0d9
zh: de5f09cd2fb0324c
/web-access/the-service:
- en: c81fdcdfd6e22861
- zh: ac906c9148942041
+ en: c9e7dc85c08e64e1
+ zh: 277c52aac58f42dc
diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md
index c5023fb00b..a3cc37f040 100644
--- a/docs/subsystems/web.md
+++ b/docs/subsystems/web.md
@@ -54,15 +54,18 @@ interface WebSearchResult {
```ts type-equiv
/**
- * One citeable source. A source always has a URL; `title`, `snippet`, and
- * `publishedAt` are optional because not every provider returns them — forcing
+ * One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
+ * and `publishedAt` are optional because not every provider returns them — forcing
* adapters to invent them would make the seam lie (Perplexity citations may be
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
*/
interface WebSearchSource {
readonly url: string
readonly title?: string
+ /** Short excerpt suited to display. */
readonly snippet?: string
+ /** Longer excerpt suited to model reading; consumers bound its length. */
+ readonly summary?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */
readonly publishedAt?: string
}
@@ -128,7 +131,7 @@ Selection never depends on registration, config, or HMR order: a capability has
The shipped Cordis, Code, and Standard presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. File sandbox presets do not govern Web network access. A deployment that needs confirmation must add a `tools/pre-execute` policy or disable fetch.
-The HTTP provider resolves each actual request, rejects non-public answers including private IPv4 reached through the active DNS64 prefix, pins the validated address set, and repeats enforcement for each same-origin redirect. A cross-origin redirect requires a new tool call and fresh public-address validation. These checks prevent SSRF access to non-public destinations but do not stop a model from sending data to a public URL.
+The HTTP provider does not restrict destination addresses: `web_fetch` reaches loopback, private-network, and cloud metadata hosts like any other host, and a model can send data to any URL it can reach. A cross-origin redirect requires a new tool call. A deployment that must keep the model away from private destinations needs network confinement, a `tools/pre-execute` policy, or no fetch tool.
## Errors
@@ -136,7 +139,7 @@ The HTTP provider resolves each actual request, rejects non-public answers inclu
## The service
-`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, resolves each hostname once, rejects any answer set containing a non-public IPv4 or IPv6 destination or an active-prefix NAT64 translation to non-public IPv4, pins the request connection to the validated addresses, repeats those checks for every same-origin redirect hop, caps redirects, bytes, characters, and time, and decodes the body; the tool owns presentation.
+`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, sends each request through the installed proxy policy, repeats URL validation for every same-origin redirect hop, caps redirects, bytes, characters, and time, and decodes the body; the tool owns presentation.
diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md
index 71f4b6353e..95d940c89b 100644
--- a/docs/subsystems/web.zh.md
+++ b/docs/subsystems/web.zh.md
@@ -54,15 +54,18 @@ interface WebSearchResult {
```ts type-equiv
/**
- * One citeable source. A source always has a URL; `title`, `snippet`, and
- * `publishedAt` are optional because not every provider returns them — forcing
+ * One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
+ * and `publishedAt` are optional because not every provider returns them — forcing
* adapters to invent them would make the seam lie (Perplexity citations may be
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
*/
interface WebSearchSource {
readonly url: string
readonly title?: string
+ /** Short excerpt suited to display. */
readonly snippet?: string
+ /** Longer excerpt suited to model reading; consumers bound its length. */
+ readonly summary?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */
readonly publishedAt?: string
}
@@ -128,7 +131,7 @@ type WebFetchBody =
已交付的 Cordis、Code 与 Standard preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。文件 sandbox preset 不管辖 Web 网络访问。需要确认步骤的部署必须添加 `tools/pre-execute` 策略或禁用抓取。
-HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前缀抵达私有 IPv4 在内的非公开结果,固定已验证的地址集合,并在每次同源重定向时重复强制执行。跨源重定向需要新的工具调用和新的公开地址校验。这些检查会阻止通过 SSRF 访问非公开目的地址,但不会阻止模型把数据发送到公开 URL。
+HTTP 提供方不限制目的地址:`web_fetch` 会像访问其他主机一样访问 loopback、内网与云元数据主机,模型也可以把数据发送到它能访问的任何 URL。跨源重定向需要新的工具调用。需要阻止模型访问私有目的地址的部署必须使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
## 错误
@@ -136,7 +139,7 @@ HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前
## 服务
-`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、对每个 hostname 只解析一次、拒绝包含任一非公开 IPv4/IPv6 目的地址或经当前前缀转换到非公开 IPv4 的 NAT64 地址的解析结果、把请求连接固定到已验证地址、对每一次同源重定向跳转重复这些校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。
+`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、经已安装的代理策略发送每个请求、对每一次同源重定向跳转重复 URL 校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml
new file mode 100644
index 0000000000..c7c546a4e9
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md
+/:
+ en: 8d967f90aee1902e
+ zh: 30735df66cafe05d
+/web-fetch-reaches-private-destinations:
+ en: 8c157d5137d0a507
+ zh: 27cef187efdb1323
+/web-fetch-reaches-private-destinations/change:
+ en: bcbce3e213b81afc
+ zh: dda264a679117c36
+/web-fetch-reaches-private-destinations/migration:
+ en: e076bdd8bd132ee8
+ zh: 26d5da33ccc8e37c
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md
new file mode 100644
index 0000000000..4b99b614b1
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md
@@ -0,0 +1,21 @@
+---
+kind: upgrade-guide
+description: "The HTTP fetch provider no longer rejects non-public destinations, and HttpFetchProvider no longer accepts an address resolver."
+---
+# `web_fetch` reaches private destinations
+
+English | [中文](guide.zh.md)
+
+## Change
+
+`@deepseek-ai/dsh-web-fetch-http` used to resolve each hostname, reject any loopback, private, link-local, or reserved answer with `WEB_BLOCKED_URL`, and pin the connection to the validated addresses. It now sends every request through the installed proxy policy and the system resolver, so `web_fetch` reaches loopback, private-network, and cloud metadata hosts. Fetches behind TUN-mode proxies with fake-IP DNS work without configuration.
+
+The package no longer exports `HttpFetchResolver`, and the `HttpFetchProvider` constructor takes only `HttpFetchLimits`.
+
+Deployments that relied on the provider to keep the model away from private services are affected, as is code that passed a resolver to `HttpFetchProvider`.
+
+## Migration
+
+1. If the model must not reach private destinations, confine the process's network access, add a `tools/pre-execute` policy that rejects those `web_fetch` URLs, or remove `@deepseek-ai/dsh-tool-web`'s fetch tool from the composition.
+2. Replace `new HttpFetchProvider(limits, resolveAddresses)` with `new HttpFetchProvider(limits)`. To direct a test request to a local server, put the server's address in the URL instead of overriding resolution.
+3. Confirm the migration with `pnpm run typecheck`; no reference to `HttpFetchResolver` remains.
diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md
new file mode 100644
index 0000000000..a0139d1949
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md
@@ -0,0 +1,21 @@
+---
+kind: upgrade-guide
+description: "HTTP 抓取提供方不再拒绝非公开目标,HttpFetchProvider 也不再接受地址解析器。"
+---
+# `web_fetch` 可以访问私有目标
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-web-fetch-http` 以前会解析每个主机名,以 `WEB_BLOCKED_URL` 拒绝任何 loopback、私有、link-local 或保留地址,并把连接固定到已校验地址。现在它通过已安装的代理策略和系统解析器发送每个请求,因此 `web_fetch` 可以访问 loopback、内网与云元数据主机。使用 fake-IP DNS 的 TUN 模式代理之后,抓取无需配置即可工作。
+
+本包不再导出 `HttpFetchResolver`,`HttpFetchProvider` 构造函数只接受 `HttpFetchLimits`。
+
+依赖该提供方阻止模型访问私有服务的部署会受影响;向 `HttpFetchProvider` 传入解析器的代码也会受影响。
+
+## 迁移
+
+1. 如果模型不得访问私有目标,请限制进程的网络访问、添加拒绝这些 `web_fetch` URL 的 `tools/pre-execute` 策略,或从组合中移除 `@deepseek-ai/dsh-tool-web` 的抓取工具。
+2. 把 `new HttpFetchProvider(limits, resolveAddresses)` 替换为 `new HttpFetchProvider(limits)`。如需把测试请求指向本地服务器,请把服务器地址写进 URL,而不是覆盖解析。
+3. 用 `pnpm run typecheck` 确认迁移完成,且不再引用 `HttpFetchResolver`。
diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml
index e96b8abe57..6f7e665a4f 100644
--- a/docs/user/guide/providers.i18n.yaml
+++ b/docs/user/guide/providers.i18n.yaml
@@ -15,8 +15,8 @@
en: 17867305a3e1b52a
zh: 876fedf1f6f563e4
/configure-models/add-a-custom-model-api/discover-models:
- en: 97318c5a115199ff
- zh: 0c455e4232cf0265
+ en: ec10ba53a00eddc6
+ zh: 857373a4e0fefee4
/configure-models/select-a-model:
en: 6a94264c99535caf
zh: f97bb0dc6a40c0d3
diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md
index 4a57c7b7bc..c1118e469f 100644
--- a/docs/user/guide/providers.md
+++ b/docs/user/guide/providers.md
@@ -32,7 +32,7 @@ The Provider ID is permanent because requests, saved sessions, model defaults, a
### Discover models
-Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: search, tick the models you want, and choose **Add selected**. Nothing is stored until you save or create the provider.
+Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: models already in the list start ticked, so search, tick the models to add, untick the ones to remove, and choose **Update list**. Configured models the endpoint does not list stay. Nothing is stored until you save or create the provider.
Discovery reads the listing formats common gateways publish, but not every endpoint answers in one of them, so treat it as a convenience rather than a guarantee: when it fails or lists nothing, add the model ids by hand and they work just the same. A built-in provider is always answered from the installed catalog, even when its base URL points at a gateway, so fetch through a custom provider to see what the gateway really serves.
diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md
index 9189f66815..ebf6147681 100644
--- a/docs/user/guide/providers.zh.md
+++ b/docs/user/guide/providers.zh.md
@@ -32,7 +32,7 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
### 探测模型
-在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。
+在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,列表中已有的模型默认勾选,搜索后勾选要添加的模型、取消勾选要移除的模型,再点**更新列表**。端点未列出的已配置模型保持不变。保存或创建提供商之前不会存储任何内容。
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。
diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml
index 5b191312a7..c6f661f22d 100644
--- a/packages/bundle/base/README.i18n.yaml
+++ b/packages/bundle/base/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 0f971f7ac7c09139
zh: ceb866da681c709c
/deepseek-ai-dsh-base/use-this-package/what-you-get:
- en: 5bfb7755e987862c
- zh: 1f0e9ea7a826a509
+ en: 2ef2653d55107eec
+ zh: 4936c0fff94b1984
/deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform:
en: 2dce12f82d6ee955
zh: d4599eb899a3cd88
diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md
index 9474a8ce63..f928d433d6 100644
--- a/packages/bundle/base/README.md
+++ b/packages/bundle/base/README.md
@@ -47,7 +47,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode
### What you get
-Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
+Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval and can reach loopback and private-network hosts. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities.
diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md
index b4e45bcaf6..132a40bab3 100644
--- a/packages/bundle/base/README.zh.md
+++ b/packages/bundle/base/README.zh.md
@@ -47,7 +47,7 @@ kind: "package-bundle"
### 你得到什么
-开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
+开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,并且可以访问 loopback 与内网主机。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。
diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css
index edf5a7bf79..611dd4eaa5 100644
--- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css
+++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css
@@ -63,5 +63,5 @@
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
- border-color: transparent;
+ --dsh-button-outline-color: transparent;
}
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index d43d47801c..492b4b4b39 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -32,6 +32,7 @@
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller",
+ "@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-input-trigger",
@@ -62,6 +63,7 @@
"@deepseek-ai/dsh-api-workspace-files": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
+ "@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-approval": "workspace:*",
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 9421bc9020..6432e3e09e 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client'
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
// The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
-import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
+import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
// Type-only service and declaration merges used by the apply world.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected,
QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps,
@@ -74,7 +75,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
/** Services required by the Chat target and its presentation registrations. */
export const inject = [
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
- 'configForms', 'remote', 'remote.session', 'sidebarRight',
+ 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight',
]
/**
@@ -203,7 +204,8 @@ export function apply(ctx: Context): void {
}),
}, TranscriptViewRow))
- const nodeSources = (binding: SessionBinding): ChatFlowDataInjected => {
+ const connectionState = (ctx.get('connection') as ConnectionHandle).state
+ const nodeSources = (binding: SessionBinding): Pick => {
const chat = chatSource(binding)
const conversation = ctx.uiConversation.binding(binding)
return {
@@ -225,7 +227,8 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId): ChatFlowDataInjected => {
const binding = ctx.sessions.binding(sessionId)
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
- return nodeSources(binding)
+ const sources = nodeSources(binding)
+ return { ...sources, hooks: { ...sources.hooks, connectionState } }
},
}, ChatFlow))
@@ -250,19 +253,22 @@ export function apply(ctx: Context): void {
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
// Files open in the right Sidebar, not in a desktop application: the
// content stays in the product, beside the conversation that produced
- // it. A relative path, or an absolute one inside the session's
- // workspace, is addressed under this session's scope,
- // `dsh-resource://file/session//`; an absolute path
- // elsewhere keeps its absolute spelling in the same Session's address.
- // Which tab type claims the
- // address is the Sidebar's decision, not this call site's.
+ // it. A relative path resolves from the Session's working directory
+ // at click time, which a directory change can move away from the
+ // workspace root; links from earlier Turns follow later changes. A
+ // path inside the workspace is addressed under this
+ // session's scope, `dsh-resource://file/session//`; an
+ // absolute path elsewhere keeps its absolute spelling in the same
+ // Session's address. Which tab type claims the address is the
+ // Sidebar's decision, not this call site's.
// A line travels as a navigation parameter, not as part of the
// address: the file is one piece of content whether it is opened at
// its top or at line 400, so the same tab is revealed and told where
// to land.
openFile: async (path, options) => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
- const url = fileAddressFor(sessionId, cwd, path)
+ const current = session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined
+ const url = fileAddressFor(sessionId, cwd, resolveWorkspacePath(current ?? undefined, path))
if (options?.line === undefined) ctx.sidebarRight.openResource(url)
else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
await Promise.resolve()
diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
index 224d5c7a4e..561632a671 100644
--- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx
@@ -25,12 +25,13 @@ function isTranscriptEcho(item: PendingInput): boolean {
/** Render the flow without adding a DOM parent or changing keyed row positions. */
export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
- useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation,
+ useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState,
useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction,
cwd, openFile, inspectCall, forkAt, loadImage, fileMentions,
}: ChatFlowSlotProps) {
const nodeStore = useChat(snapshot => snapshot.nodes)
const running = useSession(snapshot => snapshot.running)
+ const disconnected = useConnectionState(state => state === 'disconnected' || state === 'connecting')
const latestTurnAnchor = useChat(snapshot => snapshot.navigation.items().at(-1)?.anchorKey)
const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
const location = node?.location
@@ -85,7 +86,7 @@ export const ChatFlow = memo(function ChatFlow({
// Rows enter before the keyed status and echoes after it, so React never moves its DOM node.
return [
...rows, ...leadingRows, ...openingRows,
- ...running ? [] : [],
+ ...running ? [] : [],
...pendingRows,
]
})
diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
index fd145b6324..36a202c973 100644
--- a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
+++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
@@ -9,22 +9,24 @@ import css from './ChatView.module.css'
interface RunningStatusProps {
readonly startTime: number | undefined
+ /** Freeze the shown duration while the Client cannot observe the Session. */
+ readonly paused: boolean
readonly t: ChatViewSlotProps['t']
}
/**
* Show live elapsed time after the current Turn's content without announcing ticks.
- * @param props - Current Turn start time and localized copy.
+ * @param props - Current Turn start time, pause state, and localized copy.
* @returns the blue running indicator; mount only while the Session is running.
*/
-export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
+export const RunningStatus = memo(function RunningStatus({ startTime, paused, t }: RunningStatusProps) {
const [now, setNow] = useState(Date.now)
useEffect(() => {
- if (startTime === undefined) return
+ if (startTime === undefined || paused) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
- }, [startTime])
+ }, [startTime, paused])
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
duration: formatRunDuration(Math.max(1000, now - startTime), t).map(part => part.text).join(''),
})
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index a85e28798d..4bd88840f6 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -15,6 +15,7 @@ import type {
PropsRenderFactories, PropsStore, SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client'
import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read.
@@ -325,8 +326,13 @@ export interface ChatViewInjected {
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
-/** Existing Session-owned node sources shared by the view and its flow renderer. */
-export type ChatFlowDataInjected = Pick
+/** Session-owned node sources shared with the view, plus the flow's connection state. */
+export type ChatFlowDataInjected = Pick & {
+ hooks: ChatViewInjected['hooks'] & {
+ /** Client connection lifecycle; the running clock pauses while it is not connected. */
+ connectionState: ConnectionStateSource
+ }
+}
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick id === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope,
} as never)
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
+ runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', layout as never)
const sidebarRight = {
openResource: vi.fn<(address: string) => void>(),
@@ -265,6 +266,25 @@ describe('Chat inject API', () => {
await b.runtime.dispose()
})
+ it('resolves relative file paths against the Session\'s current working directory', async () => {
+ const b = await bench()
+ const { injected } = b.chatViewApi(b.rootReference)
+ await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
+ await injected.openFile('src/a.ts')
+ expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
+ // A leading `./` names the same file, so the Sidebar reuses its tab.
+ await injected.openFile('./src/a.ts')
+ expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
+ await injected.openFile('/proj/src/a.ts')
+ expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts')
+
+ // Outside the workspace, the address carries the absolute path after the Session id.
+ await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/elsewhere')
+ await injected.openFile('src/a.ts')
+ expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1//elsewhere/src/a.ts')
+ await b.runtime.dispose()
+ })
+
it('opens message HTTP(S) links in Sidebar Browser tabs', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
@@ -447,6 +467,8 @@ describe('Chat inject API', () => {
expect(flow.injected.keyedHooks.chatNode('missing')).toBe(injected.keyedHooks.chatNode('missing'))
expect(flow.injected.keyedHooks.chatNodeProcess('missing')).toBe(injected.keyedHooks.chatNodeProcess('missing'))
expect(flow.injected.keyedHooks.chatNodeBottom('missing')).toBe(injected.keyedHooks.chatNodeBottom('missing'))
+ expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state)
+ expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation)
expect(injected.fileMentions(owner)).toBeUndefined()
const mentions = { resolve: vi.fn() } as never
diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
index 6eced3135c..f5f070f6f4 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -51,6 +51,7 @@ async function bench() {
? chatSettings.scope
: stubConfigForm().scope,
} as never)
+ runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never)
runtime.ctx.provide('sidebarRightTabs', {
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 0931292539..dbbb2d4227 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -29,6 +29,7 @@ import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/c
import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { ConnectionState } from '@deepseek-ai/dsh-client-connection/client'
import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -280,18 +281,19 @@ const renderCommandSlot: React.ComponentProps['renderSlo
(_key, _owner, opts) => opts?.fallback ?? null
const renderTurnTailSlot: React.ComponentProps['renderSlot'] = () => null
-function HarnessChatFlow({ standard, owner, hookContext, renderSlot }: {
+function HarnessChatFlow({ standard, owner, hookContext, renderSlot, useConnectionState }: {
readonly standard: HarnessStandardProps
readonly owner: ChatFlowOwnerProps
readonly hookContext: ChatFlowHookContext
readonly renderSlot: ChatFlowSlotProps['renderSlot']
+ readonly useConnectionState: ChatFlowSlotProps['useConnectionState']
}) {
const hooks = useMemo(() => ({
useGroupAction: CHAT_FLOW_INJECT.hooks.groupAction(standard, hookContext),
useGroupHeaderAction: CHAT_FLOW_INJECT.hooks.groupHeaderAction(standard, hookContext),
}), [standard, hookContext])
const { renderSlot: _rootRenderSlot, __renders: _rootRenders, ...shared } = standard
- return
+ return
}
function HarnessChatNode({ standard, owner, hookContext, usePerformanceUsage, toolOwners, fallback, renderAssistantSlot }: {
@@ -456,10 +458,13 @@ function makeHarness(
if ('renderSlot' in owner || 'useStore' in owner || 'actions' in owner) {
throw new Error('Flow owner data must not replace its rendering or store authority')
}
- return
+ return
}
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (pass-through stub).
+ const connection = createSnapshotStore('connected')
+ const useConnectionState = bindSnapshotSelector(connection)
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}>
const props: HarnessStandardProps = {
renderFactorySlot,
@@ -537,6 +542,7 @@ function makeHarness(
}
return {
set, setSession: session.set, setChat: chatSource.set, ChatView, props,
+ setConnection: (state: ConnectionState) => { connection.set(state) },
openFile, openUserFile, loadOlder, loadThrough, openView,
setOutline: (value: unknown) => { outlineValue = value },
chatScroll, forkAt, toolOwners,
@@ -3748,6 +3754,24 @@ describe('ChatView', () => {
expect(vi.getTimerCount()).toBe(0)
}))
+ it('the running clock freezes while the connection is recovering', withClock(10_000, () => {
+ const h = makeHarness(
+ { nodes: [{ ...userInTurn(1, 'go', 1), time: 2_000 }], turnTimings: new Map([[1, { startTime: 1_000 }]]) },
+ { running: true },
+ )
+ const view = render()
+ const content = runningContent(view.container)!
+ expect(content.textContent).toBe('深度求索中,用时 9秒 ···')
+ act(() => { h.setConnection('connecting') })
+ act(() => { vi.advanceTimersByTime(5_000) })
+ expect(content.textContent).toBe('深度求索中,用时 9秒 ···')
+ act(() => { h.setConnection('disconnected') })
+ act(() => { vi.advanceTimersByTime(5_000) })
+ expect(content.textContent).toBe('深度求索中,用时 9秒 ···')
+ act(() => { h.setConnection('connected') })
+ expect(content.textContent).toBe('深度求索中,用时 19秒 ···')
+ }))
+
it('the running clock reads hours once the turn passes an hour', withClock(3_600_000, () => {
const startTime = 1_000
const trigger = { ...userInTurn(1, 'go', 1), time: startTime + 1 }
diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
index 2539c7357d..e6992183a5 100644
--- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
+++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx
@@ -52,6 +52,7 @@ async function bench() {
developerTools: { enabled: createSnapshotStore(true) },
get: (namespace: string) => namespace === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope,
} as never)
+ runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', { openRightbar: () => {}, closeRightbar: () => {} } as never)
runtime.ctx.provide('sidebarRight', { openResource: () => {}, openTab: () => {} } as never)
runtime.ctx.provide('sidebarRightTabs', {
diff --git a/packages/client/ui-chat/tests/running-status.client.spec.tsx b/packages/client/ui-chat/tests/running-status.client.spec.tsx
index 6813410c56..c7e5f0f0b2 100644
--- a/packages/client/ui-chat/tests/running-status.client.spec.tsx
+++ b/packages/client/ui-chat/tests/running-status.client.spec.tsx
@@ -13,11 +13,13 @@ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(5_000) })
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
function statusHarness(startTime?: number) {
- const view = render()
+ const view = render()
return {
...view,
content: () => view.container.querySelector('[data-chat-running] > :last-child'),
- set: (nextStartTime?: number) => { view.rerender() },
+ set: (nextStartTime?: number, paused = false) => {
+ view.rerender()
+ },
}
}
@@ -66,4 +68,15 @@ describe('RunningStatus', () => {
expect(view.content()?.textContent).not.toContain('-')
})
+ it('freezes the duration while paused and resumes from the Turn start', () => {
+ const view = statusHarness(1_000)
+ expect(view.content()?.textContent).toBe('深度求索中,用时 4秒 ···')
+ view.set(1_000, true)
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => { vi.advanceTimersByTime(5_000) })
+ expect(view.content()?.textContent).toBe('深度求索中,用时 4秒 ···')
+ view.set(1_000)
+ expect(view.content()?.textContent).toBe('深度求索中,用时 9秒 ···')
+ expect(vi.getTimerCount()).toBe(1)
+ })
})
diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json
index e03a2b0a90..528a54b447 100644
--- a/packages/client/ui-chat/tsconfig.client.json
+++ b/packages/client/ui-chat/tsconfig.client.json
@@ -69,6 +69,9 @@
{
"path": "../../subagent/subagent"
},
+ {
+ "path": "../connection/tsconfig.client.json"
+ },
{
"path": "../locale/tsconfig.client.json"
},
diff --git a/packages/client/ui-directory-picker-browse/README.i18n.yaml b/packages/client/ui-directory-picker-browse/README.i18n.yaml
index d5e55c38ed..744d1fd82d 100644
--- a/packages/client/ui-directory-picker-browse/README.i18n.yaml
+++ b/packages/client/ui-directory-picker-browse/README.i18n.yaml
@@ -21,8 +21,8 @@
en: 18e10884b02f288f
zh: 06debd0a4a88bdac
/deepseek-ai-dsh-client-ui-directory-picker-browse/understand-the-implementation:
- en: fb217b7d152731db
- zh: 9ee3c0a9f15a5a29
+ en: 66780e5a328376eb
+ zh: 7c58a39492a70967
/deepseek-ai-dsh-client-ui-directory-picker-browse/further-exploration:
en: 1aa7d7ba154532ed
zh: 5bda0ac2de26758c
diff --git a/packages/client/ui-directory-picker-browse/README.md b/packages/client/ui-directory-picker-browse/README.md
index ff4e9cc8e3..10f6789571 100644
--- a/packages/client/ui-directory-picker-browse/README.md
+++ b/packages/client/ui-directory-picker-browse/README.md
@@ -41,6 +41,8 @@ Step through folders, edit the path directly, or filter the last pane by prefix;
The dialog is a 680×500 Miller-column view (clamped on short or narrow viewports), driven by the host `listDirectory` and `createDirectory` primitives through `ctx.workspaces`. Both registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration; the dialog's copy lives in this package's own locale namespace so the two dictionaries land as a unit. Browse failures stay inside the dialog's own alert surfaces, so this occupant never drives the owner's `onError` arm. The node half is an empty `apply` that keeps the plugin on the host roster.
+The New folder and Cancel actions use the shared [Button](../ui-primitives/README.md)'s inset hairline, which preserves their layout border and keeps the stroke complete at fractional button widths.
+
-----
diff --git a/packages/client/ui-directory-picker-browse/README.zh.md b/packages/client/ui-directory-picker-browse/README.zh.md
index 4b7ef6241d..489a2005bd 100644
--- a/packages/client/ui-directory-picker-browse/README.zh.md
+++ b/packages/client/ui-directory-picker-browse/README.zh.md
@@ -41,6 +41,8 @@ kind: "package-reference"
对话框是 680×500 的 Miller 分栏视图(在较矮或较窄的视口中限制尺寸),经 `ctx.workspaces` 驱动宿主的 `listDirectory` 与 `createDirectory` 原语。两处注册经嵌套的 `ctx.slots.inject()` 调用作为一次事务性效果安装,因为任一声明条目都可能晚些激活或替换其声明;对话框文案注册在本包自己的 locale 命名空间下,让两份字典作为一个单元落地。浏览类失败留在对话框自己的提示区内,因此本填充从不驱动持有方的 `onError` 分支。node 半部是一个空 `apply`,让插件留在宿主名单上。
+New folder 和 Cancel 操作使用共享 [Button](../ui-primitives/README.zh.md) 的内侧细描边,保留布局边框,并使小数宽度的按钮保持完整描边。
+
-----
diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml
index 19a4d854bd..1cf10e5b6e 100644
--- a/packages/client/ui-plugin-manager/README.i18n.yaml
+++ b/packages/client/ui-plugin-manager/README.i18n.yaml
@@ -36,8 +36,8 @@
en: 54d7666c630da471
zh: d7ded257d0fd5559
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs:
- en: 25451392e1118869
- zh: 8ba84f8fdbb47b3f
+ en: 1de4fe390871d0da
+ zh: 1723ae12ccbe3697
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation:
en: 203168fc87e939bd
zh: 72d0ccd2c54f3f44
@@ -48,8 +48,8 @@
en: 21a0bbe8eb92229c
zh: 096ff63db61992bb
/deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/configuration-slots:
- en: 928c1bcaace6ce0c
- zh: 64d2aad4f01b324c
+ en: 09435fc2e7d349b0
+ zh: 93551bf54cc805f3
/deepseek-ai-dsh-client-ui-plugin-manager/further-exploration:
en: f9a42dfed0360d6a
zh: 455a70bcb6c69624
diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md
index 1cf636583c..05dd389475 100644
--- a/packages/client/ui-plugin-manager/README.md
+++ b/packages/client/ui-plugin-manager/README.md
@@ -108,7 +108,7 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a
### Page tabs
-Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
+Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here.
-----
@@ -136,7 +136,7 @@ The add control reuses `Button`, `Menu`, and `MenuItemButton`. Both installation
Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form.
-The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker.
+The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. The registration-owned navigation store selects the main list, the full experimental plugin list, a bundle, an official plugin, or a bundle row; switching away from the Plugins panel resets it to the list, while React remounts preserve the selected target. A fresh registration starts on the main list. Installation highlighting scrolls to a card only when it is on the current page; it does not change the selected page. Other Client plugins inject `pluginNavigation` and call `ctx.pluginNavigation.openBundle(packageName)` to open bundle details without changing the current Session. The target survives the initial inventory load; an absent bundle displays the list. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Cards and detail pages show the Experimental marker only for packages in `EXPERIMENTAL_BUNDLES` in [presentation.ts](src/client/presentation.ts); package-name prefixes do not select the marker.
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md
index b6e5e58b03..c1849d92d1 100644
--- a/packages/client/ui-plugin-manager/README.zh.md
+++ b/packages/client/ui-plugin-manager/README.zh.md
@@ -108,7 +108,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
### 页面标签页
-其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
+其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。
-----
@@ -136,7 +136,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。
-页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。
+页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。注册拥有的导航 store 选择主列表、完整的实验性插件列表、某个组合包、某个官方插件或组合包的某一行;切换离开插件面板时重置为列表,React 重新挂载则保留所选目标。新的注册从主列表开始。安装后的高亮仅在当前页面有对应卡片时滚动定位,不改变所选页面。其他 Client 插件注入 `pluginNavigation`,调用 `ctx.pluginNavigation.openBundle(packageName)` 即可打开组合包详情,不改变当前 Session。首次读取清单期间保留导航目标;组合包不存在时显示列表。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 卡片和详情页仅为 [presentation.ts](src/client/presentation.ts) 中 `EXPERIMENTAL_BUNDLES` 名单内的包显示实验性标签,包名前缀不决定标签。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
index fee58906fa..4cc7c0246f 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
@@ -467,7 +467,7 @@
and the outline hover rebinds to a red wash instead of the gray one. */
.danger {
color: var(--dsw-alias-state-error-primary);
- border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
+ --dsh-button-outline-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
--dsw-alias-interactive-bg-hover: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
}
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
index 68ad27e43e..c467185d12 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
@@ -114,6 +114,7 @@ const EXTENSION_BUNDLES = [
]
/** The optional bundles the main list holds back until the profile picks them; the More list shows them from the start. */
const MORE_BUNDLES = [
+ '@deepseek-ai/dsh-cua-bundle',
'@deepseek-ai/dsh-experimental-tool-worktree',
'@deepseek-ai/dsh-experimental-cot-translation-bundle',
'@deepseek-ai/dsh-experimental-terminal-bundle',
@@ -1577,6 +1578,9 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
const showsMainList = showsCards && !showsMoreList
const installedCards = onInstalled && showsCards
const openPkgInMore = openPkg !== undefined && openPkg.official && detailFromMore
+ const returnTab = view.kind === 'package' || view.kind === 'row' ? view.returnTab : undefined
+ const backToMore = openPkgInMore && returnTab === undefined
+ const pkgOrigin = returnTab !== undefined ? { returnTab } : backToMore ? { from: 'more' as const } : {}
// The Installed tab's panel, whatever it currently shows; the More list is not a tab panel.
const installedPanel = tabs.length === 0 || showsMoreList
? {}
@@ -1588,7 +1592,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
}
const configure = (pkg: PackageView): RowConfigure => ({
has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)),
- open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId, ...openPkgInMore ? { from: 'more' } : {} }) },
+ open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId, ...pkgOrigin }) },
})
const updates = updatesNote(mine, state.versions, t)
const updatePkg = state.update === null ? undefined : state.packages.find(pkg => pkg.name === state.update?.packageName)
@@ -1683,7 +1687,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
)
@@ -1736,7 +1740,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
t={t}
resolveText={resolveText}
renderSlot={renderSlot}
- onBack={() => { setView({ kind: 'package', name: openPkg.name, ...openPkgInMore ? { from: 'more' } : {} }) }}
+ onBack={() => { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }}
/>
)
: null}
@@ -1751,13 +1755,16 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
configured={ledger.bundles.has(openPkg.name)}
configure={configure(openPkg)}
renderSlot={renderSlot}
- inMore={openPkgInMore}
+ inMore={backToMore}
detail={(
)}
- onBack={() => { setView({ kind: openPkgInMore ? 'more' : 'list' }) }}
+ onBack={() => {
+ if (returnTab !== undefined) setTab(returnTab)
+ setView({ kind: backToMore ? 'more' : 'list' })
+ }}
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
onUninstall={() => { props.uninstall(openPkg.name) }}
onUpdate={() => { props.update(openPkg.name) }}
diff --git a/packages/client/ui-plugin-manager/src/client/navigation-store.ts b/packages/client/ui-plugin-manager/src/client/navigation-store.ts
index 7327f7ceb6..e19a6b3db8 100644
--- a/packages/client/ui-plugin-manager/src/client/navigation-store.ts
+++ b/packages/client/ui-plugin-manager/src/client/navigation-store.ts
@@ -1,13 +1,16 @@
/** Plugin page selection shared by the page and cross-plugin navigation. */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
-/** The main or additional plugin list, or one bundle, official item, or bundle row of the Installed tab. */
+/**
+ * The main or additional plugin list, or one bundle, official item, or bundle row of the Installed tab. A bundle or row
+ * opened from a contributed tab names it in `returnTab`, and the bundle page's back returns to that tab's list.
+ */
type View =
| { readonly kind: 'list' }
| { readonly kind: 'more' }
- | { readonly kind: 'package'; readonly name: string; readonly from?: 'more' }
+ | { readonly kind: 'package'; readonly name: string; readonly from?: 'more'; readonly returnTab?: string }
| { readonly kind: 'item'; readonly id: string }
- | { readonly kind: 'row'; readonly name: string; readonly rowId: string; readonly from?: 'more' }
+ | { readonly kind: 'row'; readonly name: string; readonly rowId: string; readonly from?: 'more'; readonly returnTab?: string }
/** `installed` is the page's own tab; any other value is the id of a `plugins.tab` entry. */
type NavigationState = { view: View; tab: string }
diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts
index 35100e32df..b636c67e04 100644
--- a/packages/client/ui-plugin-manager/src/client/presentation.ts
+++ b/packages/client/ui-plugin-manager/src/client/presentation.ts
@@ -108,6 +108,23 @@ export function shortName(name: string): string {
return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
}
+/** Official bundles that display the Experimental badge. */
+const EXPERIMENTAL_BUNDLES: readonly string[] = [
+ '@deepseek-ai/dsh-cua-bundle',
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
+ '@deepseek-ai/dsh-experimental-voice-input-bundle',
+ '@deepseek-ai/dsh-experimental-auto-review',
+ '@deepseek-ai/dsh-experimental-inspector-profile',
+ '@deepseek-ai/dsh-experimental-tool-worktree',
+ '@deepseek-ai/dsh-experimental-cot-translation-bundle',
+ '@deepseek-ai/dsh-experimental-terminal-bundle',
+ '@deepseek-ai/dsh-experimental-session-search',
+ '@deepseek-ai/dsh-experimental-session-titles-bundle',
+ '@deepseek-ai/dsh-experimental-badge-skill-bundle',
+ '@deepseek-ai/dsh-experimental-ralph-bundle',
+ '@deepseek-ai/dsh-experimental-plugin-market-bundle',
+]
+
/**
* Resolve installed package metadata without changing its technical identity.
* @param pkg - package identity and local metadata.
@@ -120,7 +137,7 @@ export function packageText(
return {
title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title),
description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined,
- beta: pkg.name.startsWith('@deepseek-ai/dsh-experimental-'),
+ beta: EXPERIMENTAL_BUNDLES.includes(pkg.name),
}
}
diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts
index b7a2c3fea2..1450f2bb96 100644
--- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts
+++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts
@@ -100,7 +100,7 @@ export interface PluginTabProps {
*/
readonly install: (target: PluginTabInstall) => void
/**
- * Switch to the Installed tab and open a bundle's page.
+ * Switch to the Installed tab and open a bundle's page, whose back button returns to the calling tab.
* @param packageName - npm package name of the bundle.
*/
readonly openPackage: (packageName: string) => void
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
index ee522a322c..9998a92b15 100644
--- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
@@ -249,10 +249,14 @@ describe('PluginManagerPage', () => {
fireEvent.click(screen.getByRole('button', { name: 'Discover install' }))
expect(actions.installFrom).toHaveBeenCalledWith({ spec: 'dsh-a@1.0.0', registry: 'https://r.example/', title: 'Alpha', version: '1.0.0' })
fireEvent.click(screen.getByRole('button', { name: 'Discover open' }))
- expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-better-sidebar' } })
+ expect(navigation.getSnapshot()).toEqual({ tab: 'installed', view: { kind: 'package', name: 'dsh-better-sidebar', returnTab: 'discover' } })
expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).not.toBeNull()
- // A detail page hides the tabs; a contributed tab that leaves the ledger falls back to Installed.
+ // A detail page hides the tabs; its back button returns to the tab that opened it.
expect(screen.queryByRole('tablist')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+ expect(navigation.getSnapshot()).toEqual({ tab: 'discover', view: { kind: 'list' } })
+ expect(screen.getByRole('tab', { name: 'Discover' }).getAttribute('aria-selected')).toBe('true')
+ // A contributed tab that leaves the ledger falls back to Installed.
act(() => { navigation.actions.setView({ kind: 'list' }); navigation.actions.setTab('discover') })
setTabs([])
expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
@@ -273,7 +277,7 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
})
- it('keeps the Installed tab panel through loading and the More list out of the tabs', () => {
+ it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => {
const worktree = '@deepseek-ai/dsh-experimental-tool-worktree'
const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, {
'plugins.tab:discover': (_view, owner) => (
@@ -292,10 +296,39 @@ describe('PluginManagerPage', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
fireEvent.click(screen.getByRole('button', { name: 'Discover open' }))
expect(document.querySelector(`[data-plugin-detail="${worktree}"]`)).not.toBeNull()
+ // An official plugin of the More list opened from Discover returns to Discover, not to the More list.
+ fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+ expect(navigation.getSnapshot()).toEqual({ tab: 'discover', view: { kind: 'list' } })
+ fireEvent.click(screen.getByRole('tab', { name: en.installedTab }))
+ act(() => { navigation.actions.setView({ kind: 'more' }) })
+ fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', worktree) }))
fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', en.moreTitle) }))
expect(screen.getByRole('heading', { name: en.moreTitle, level: 1 })).toBeTruthy()
})
+ it('keeps the opening tab through a row page opened from a bundle page', () => {
+ const { navigation, setTabs } = renderTab({ packages: [pkg({ rows: [row()] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, {
+ 'plugins.tab:discover': (_view, owner) => (
+
+ ),
+ 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'Config summary' : ,
+ }, {}, [{ id: 'discover', label: 'Discover' }])
+ fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Discover open' }))
+ fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') }))
+ expect(navigation.getSnapshot().view).toEqual({ kind: 'row', name: 'dsh-better-sidebar', rowId: 'sidebar', returnTab: 'discover' })
+ fireEvent.click(screen.getByRole('button', { name: en.backToPackage.replace('{name}', 'dsh-better-sidebar') }))
+ expect(navigation.getSnapshot().view).toEqual({ kind: 'package', name: 'dsh-better-sidebar', returnTab: 'discover' })
+ fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+ expect(navigation.getSnapshot()).toEqual({ tab: 'discover', view: { kind: 'list' } })
+ // A calling tab that left the ledger while the page was open falls back to Installed.
+ fireEvent.click(screen.getByRole('button', { name: 'Discover open' }))
+ setTabs([])
+ fireEvent.click(screen.getByRole('button', { name: en.backToList }))
+ expect(navigation.getSnapshot().tab).toBe('installed')
+ expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
+ })
+
it('shows a confirmed version by name above its spec, as a check and a refusal with retry, without the spec form or another registry', () => {
const confirmed: InstallState = { ...IDLE_INSTALL, open: true, spec: 'dsh-a@1.0.0', confirmed: { title: 'Alpha', version: '1.0.0' }, registries: REGISTRIES }
const { actions, set } = renderTab({ install: { ...confirmed, phase: 'checking' } })
@@ -764,11 +797,12 @@ describe('PluginManagerPage', () => {
})
it.each([
- '@deepseek-ai/dsh-experimental-agent-team-profile',
- '@deepseek-ai/dsh-experimental-auto-review',
- '@deepseek-ai/dsh-experimental-fixture-input',
- '@acme/dsh-local-tools',
- ])('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name) => {
+ ['@deepseek-ai/dsh-experimental-agent-team-profile', true],
+ ['@deepseek-ai/dsh-experimental-auto-review', true],
+ ['@deepseek-ai/dsh-experimental-fixture-input', false],
+ ['@deepseek-ai/dsh-cua-bundle', true],
+ ['@acme/dsh-local-tools', false],
+ ] as const)('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name, experimental) => {
const meta = {
title: { en: 'Installed tools', zh: '已安装工具' },
description: { en: 'Tools on this host.', zh: '本机工具。' },
@@ -783,7 +817,7 @@ describe('PluginManagerPage', () => {
expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description(dict))
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
expect(screen.queryByText('Original metadata.')).toBeNull()
- expect(screen.queryByText(dict.statusBeta) !== null).toBe(name.startsWith('@deepseek-ai/dsh-experimental-'))
+ expect(screen.queryByText(dict.statusBeta) !== null).toBe(experimental)
}
assertCard(en)
setLanguage(zh)
@@ -796,6 +830,7 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict))
expect(screen.getByText(description(dict))).toBeTruthy()
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
+ expect(screen.queryByText(dict.statusBeta) !== null).toBe(experimental)
expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy()
expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', title(dict)) })).toBeTruthy()
}
@@ -1100,6 +1135,34 @@ describe('PluginManagerPage', () => {
expect(screen.getByRole('heading', { name: en.basicTitle })).toBeTruthy()
})
+ it.each([en, zh])('keeps disabled CUA on the secondary page with experimental labels', (dict) => {
+ const name = '@deepseek-ai/dsh-cua-bundle'
+ const entry = (enabled: boolean) => pkg({ name, official: true, enabled, meta: { title: 'Computer Use' } })
+ const b = renderTab({ packages: [entry(false)] })
+ b.setLanguage(dict)
+ expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: dict.moreAction }))
+ const card = document.querySelector(`[data-plugin-package="${name}"]`) as HTMLElement
+ expect(within(card).getByText(dict.statusBeta)).toBeTruthy()
+ fireEvent.click(within(card).getByRole('button', { name: dict.openDetail.replace('{name}', 'Computer Use') }))
+ const detail = document.querySelector('[data-plugin-detail]') as HTMLElement
+ expect(within(detail).getByText(dict.statusBeta)).toBeTruthy()
+ fireEvent.click(within(detail).getByRole('switch', { name: dict.enableToggle.replace('{name}', 'Computer Use') }))
+ expect(b.actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, true)
+ b.set({ packages: [entry(true)] })
+ fireEvent.click(screen.getByRole('button', { name: dict.backToPackage.replace('{name}', dict.moreTitle) }))
+ expect(screen.getByRole('heading', { name: dict.moreTitle, level: 1 })).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: dict.backToList }))
+ const enabledCard = document.querySelector(`[data-plugin-package="${name}"]`) as HTMLElement
+ expect(within(enabledCard).getByText(dict.statusBeta)).toBeTruthy()
+ fireEvent.click(within(enabledCard).getByRole('switch', { name: dict.enableToggle.replace('{name}', 'Computer Use') }))
+ expect(b.actions.setEnabled).toHaveBeenLastCalledWith(name, false)
+ b.set({ packages: [entry(false)] })
+ expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: dict.moreAction }))
+ expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeTruthy()
+ })
+
it('returns from package details to the secondary page and retains it across React remounts', () => {
const name = '@deepseek-ai/dsh-experimental-terminal-bundle'
const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] })
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 07d93dc975..9c66b1615a 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -18,20 +18,20 @@
en: 5875d977b12a64f4
zh: a89693f28c608558
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: 03388f40dc593990
- zh: 5f1541a6e20c8cfe
+ en: adaaabb712de1a3d
+ zh: 45d6286e39880274
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
- en: 4b6e7c1dd8d8c2a7
- zh: 227747b459518e37
+ en: 3a31ad8de6dd0af8
+ zh: 6b22881a929c33df
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
- en: 0b4082dbb778476f
- zh: b6daaaf9de675b57
+ en: 846ac0f563a53a64
+ zh: bb89d7647bdcc1ab
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
en: 44eb32370cbeafe8
zh: 728009c66375c6bb
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index f2d850af90..e46d3caa0b 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -42,7 +42,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| Export | What it is |
|---|---|
-| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Its ref targets the native button for focus and overlay anchoring. |
+| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Outlined actions use an inset hairline. Its ref targets the native button for focus and overlay anchoring. |
| `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. |
| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. |
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
@@ -102,7 +102,7 @@ The catalog above lists what each export is for; this section covers the behavio
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
@@ -123,7 +123,7 @@ The atoms cannot read the application locale, so every piece of user-facing copy
## Understand the implementation
-`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
+`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index ee779ff616..903cc88186 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -42,7 +42,7 @@ kind: "package-library"
| 导出 | 是什么 |
|---|---|
-| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。ref 指向原生按钮,供焦点控制与浮层锚定使用。 |
+| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。描边操作使用内侧细线。ref 指向原生按钮,供焦点控制与浮层锚定使用。 |
| `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 |
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
@@ -102,7 +102,7 @@ kind: "package-library"
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
@@ -123,7 +123,7 @@ kind: "package-library"
## 理解实现
-`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
+`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css
index 731c593f7a..cf0560a80f 100644
--- a/packages/client/ui-primitives/src/Button.module.css
+++ b/packages/client/ui-primitives/src/Button.module.css
@@ -50,10 +50,11 @@
background: var(--dsw-alias-interactive-bg-active);
}
-/* Outlined actions use the same neutral stroke as settings links. */
+/* The transparent border preserves layout; the inset stroke stays complete at fractional widths. */
.outline {
- border: 0.5px solid var(--dsw-alias-border-l3);
+ border: 0.5px solid transparent;
background: transparent;
+ box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsh-button-outline-color, var(--dsw-alias-border-l3));
}
.outline:hover:not(:disabled) {
diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx
index 31a6edd78f..26467dddda 100644
--- a/packages/client/ui-primitives/src/Button.tsx
+++ b/packages/client/ui-primitives/src/Button.tsx
@@ -18,7 +18,7 @@ type ButtonProps = {
/**
* Render a button.
- * @param props.variant - visual family (default 'ghost').
+ * @param props.variant - visual family (default 'ghost'); outlined buttons use a DPR-aware inset stroke.
* @param props.size - 'md' 36px control with 12px corners or 'sm' 28px control with 8px corners.
* @param props.icon - optional leading 16px icon node.
* @param ref - native button for focus management and overlay anchors.
diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts
index 46559c3f47..8f56d3b7b4 100644
--- a/packages/client/ui-primitives/src/markdown/parse.ts
+++ b/packages/client/ui-primitives/src/markdown/parse.ts
@@ -18,6 +18,9 @@ import { math } from 'micromark-extension-math'
import { cjkFriendlyStrong } from './cjkFriendlyStrong.ts'
import { mathCompatibility } from './mathCompatibility.ts'
+/** GFM strikethrough requires `~~`; a single `~` stays literal, as in ranges such as `10~20`. */
+const gfmOptions = { singleTilde: false }
+
/**
* Parse GFM markdown (the streaming arm's grammar: no math, so incomplete
* TeX never flashes KaTeX errors mid-stream).
@@ -26,7 +29,7 @@ import { mathCompatibility } from './mathCompatibility.ts'
*/
export function parseGfm(text: string): Root {
return recoverLocalImages(fromMarkdown(text, {
- extensions: [gfm(), cjkFriendlyStrong()],
+ extensions: [gfm(gfmOptions), cjkFriendlyStrong()],
mdastExtensions: [gfmFromMarkdown()],
}), text)
}
@@ -39,7 +42,7 @@ export function parseGfm(text: string): Root {
*/
export function parseGfmWithMath(text: string): Root {
return recoverLocalImages(fromMarkdown(text, {
- extensions: [gfm(), cjkFriendlyStrong(), mathCompatibility(), math()],
+ extensions: [gfm(gfmOptions), cjkFriendlyStrong(), mathCompatibility(), math()],
mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
}), text)
}
diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
index 44f56b97ca..d03035ee29 100644
--- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
import { focusWithoutRing } from '../src/focus.ts'
+import buttonCss from '../src/Button.module.css'
afterEach(cleanup)
@@ -45,9 +46,27 @@ describe('Button', () => {
expect(onClick).not.toHaveBeenCalled()
})
- it('outline variant renders a bordered cancel-style button', () => {
- render()
- expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
+ it('selects the outline stylesheet for outlined actions only', () => {
+ const { rerender } = render()
+ const button = screen.getByRole('button', { name: 'Cancel' })
+ expect(button.classList.contains(buttonCss.outline!)).toBe(true)
+ for (const variant of ['primary', 'ghost', 'toolbar'] as const) {
+ rerender()
+ expect(button.classList.contains(buttonCss.outline!)).toBe(false)
+ }
+ })
+
+ it('keeps the native element and focus when the variant changes', () => {
+ const ref = createRef()
+ const { rerender } = render()
+ const button = screen.getByRole('button')
+ button.focus()
+ rerender()
+ expect(ref.current).toBe(button)
+ expect(document.activeElement).toBe(button)
+ rerender()
+ expect(ref.current).toBe(button)
+ expect(document.activeElement).toBe(button)
})
})
diff --git a/packages/client/ui-primitives/tests/button-styles.client.spec.ts b/packages/client/ui-primitives/tests/button-styles.client.spec.ts
new file mode 100644
index 0000000000..718a8c0cdd
--- /dev/null
+++ b/packages/client/ui-primitives/tests/button-styles.client.spec.ts
@@ -0,0 +1,34 @@
+/** Shared Button geometry and inset stroke declarations. */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const css = readFileSync(fileURLToPath(new URL('../src/Button.module.css', import.meta.url)), 'utf8')
+
+function declarations(selector: string): string {
+ const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const rule = new RegExp(`\\.${escaped}\\s*\\{([^}]*)\\}`).exec(css)
+ if (rule === null) throw new Error(`Button.module.css has no .${selector} rule`)
+ return rule[1] ?? ''
+}
+
+describe('Button styles', () => {
+ it('retains a transparent layout border and paints the DPR-aware stroke inside it', () => {
+ const outline = declarations('outline')
+ expect(outline).toContain('border: 0.5px solid transparent;')
+ expect(outline).toContain('box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px)')
+ expect(outline).toContain('var(--dsh-button-outline-color, var(--dsw-alias-border-l3))')
+ })
+
+ it('limits the inset stroke to outlined buttons', () => {
+ for (const selector of ['button', 'primary', 'ghost:hover:not(:disabled)', 'toolbar']) {
+ expect(declarations(selector)).not.toContain('box-shadow')
+ }
+ expect(declarations('outline:hover:not(:disabled)')).not.toContain('box-shadow')
+ })
+
+ it('keeps the shared medium and small control heights', () => {
+ expect(declarations('md')).toContain('height: 36px;')
+ expect(declarations('sm')).toContain('height: 28px;')
+ })
+})
diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
index 553f60233a..13db7b9302 100644
--- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
@@ -61,6 +61,16 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
})
+ it('keeps single tildes literal and strikes only double-tilde runs', () => {
+ const source = '约 10~20 分钟,或 30~40 分钟,~~删除~~'
+ for (const streaming of [false, true]) {
+ const rendered = render()
+ expect([...rendered.container.querySelectorAll('del')].map(node => node.textContent)).toEqual(['删除'])
+ expect(rendered.container.textContent).toContain('约 10~20 分钟,或 30~40 分钟')
+ rendered.unmount()
+ }
+ })
+
it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => {
const cases = [
['**注意:**内容', '注意:'],
diff --git a/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css b/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css
index 3b01fb89c8..3d07ca1821 100644
--- a/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css
+++ b/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css
@@ -92,7 +92,7 @@
height: 32px;
min-width: 58px;
padding: 0 12px;
- border: 0.5px solid var(--dsw-alias-border-l2);
+ --dsh-button-outline-color: var(--dsw-alias-border-l2);
border-radius: var(--dsw-radius-md);
font-size: 13px;
line-height: 20px;
diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.module.css b/packages/client/ui-settings-account/src/client/SignInDialog.module.css
index 4c1847d778..91ae61410b 100644
--- a/packages/client/ui-settings-account/src/client/SignInDialog.module.css
+++ b/packages/client/ui-settings-account/src/client/SignInDialog.module.css
@@ -7,7 +7,7 @@
.actions { box-sizing: border-box; display: flex; justify-content: flex-end; align-items: center; width: 100%; padding: 0 24px; gap: 8px; }
.primaryButton, .secondaryButton { box-sizing: border-box; min-width: 72px; height: 36px; border-radius: var(--dsw-radius-md); font-size: 14px; line-height: 22px; }
.primaryButton { padding: 0 16px; font-weight: 500; }
-.secondaryButton { padding: 0 14px; border: 0.5px solid var(--dsw-alias-border-l2); }
+.secondaryButton { padding: 0 14px; --dsh-button-outline-color: var(--dsw-alias-border-l2); }
.description { margin: 0; padding: 0 24px; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); }
.link { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
.spinner { flex: none; animation: spin 1s linear infinite; }
diff --git a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
index 11a42537fe..8c6d11c688 100644
--- a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx
@@ -94,6 +94,7 @@ async function bench() {
developerTools: { enabled: createSnapshotStore(true) },
get: formFor,
} as never)
+ runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', { openRightbar: () => {}, closeRightbar: () => {} } as never)
runtime.ctx.provide('sidebarRight', { openResource: () => {}, openTab: () => {} } as never)
runtime.ctx.provide('sidebarRightTabs', {
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index f48e27b281..fac034b195 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 2ab40f99bd12dc17
zh: 2b5fd79885cb85fb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers:
- en: 9d533c65c82cd4a4
- zh: 0a5da334e127c035
+ en: c0b04b2b11af60d3
+ zh: d195eec281e445cb
/deepseek-ai-dsh-client-ui-settings-models/use-this-package/first-run-dialogs:
en: 589397a35f82244e
zh: 342fbceee2c173a2
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index c913910fe2..016dd7efd1 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -65,7 +65,7 @@ Empty pi-ai capacity fields display `1M` for the context window and `128K` for m
### Adding and deleting providers
-**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
+**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing changes until **Update list**. The picker opens with the configured models checked, or every candidate when the list is empty and the route serves its whole catalog; **Update list** appends newly checked candidates and removes unchecked configured models from the draft, while configured models the reply does not list stay. Rows without an id do not count as configured, a repeated candidate id is listed once, and **Update list** is disabled when it would empty a non-empty list, since an empty list serves the whole catalog. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** and **Deselect all** change only the visible results, so a hidden configured model is not removed unseen. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
### First-run dialogs
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index dee1384e0b..3937cc80d4 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -65,7 +65,7 @@ pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token
### 新增与删除提供商
-**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
+**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**更新列表**才会改变草稿。选择器打开时勾选已配置的模型;列表为空、路由提供完整 catalog 时勾选全部候选。**更新列表**会追加新勾选的候选,并从草稿中移除取消勾选的已配置模型;回复中未列出的已配置模型保持不变。没有 id 的行不算已配置,重复的候选 id 只列出一次;若会把非空列表清空,**更新列表**会被禁用,因为空列表表示提供完整 catalog。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**与**取消全选**只改变可见结果,以免在看不见时移除已配置的模型。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
### 首次运行弹窗
diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
index 4e6633ee5f..8f9dd4a68d 100644
--- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx
@@ -7,7 +7,11 @@
* catalog, so a row is only ever added deliberately. Fetching asks the endpoint
* **the form currently shows** — including a key typed but not yet saved — so
* adding a provider is one pass instead of save-then-return; the reply is
- * candidates the user picks from, never configuration written behind them.
+ * candidates the user picks from, never configuration written behind them. The
+ * picker opens with the configured models checked, so confirming it adds the
+ * newly checked candidates and removes the unchecked configured ones in the
+ * draft; configured rows the reply does not list are kept, and a confirmation
+ * that would empty the list is refused.
*
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
* with no readable listing) is not a dead end: the failure is shown next to the
@@ -171,7 +175,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const catalog = inheritedCatalog?.provider === catalogProvider ? inheritedCatalog?.models : undefined
const inputDefaults = useMemo(() => new Map(catalog?.map(model => [model.id, model.inputModalities])), [catalog])
const [candidates, setCandidates] = useState(undefined)
- const [picked, setPicked] = useState>(new Set())
+ // Candidates whose checkbox the user flipped from its default. The default
+ // is read from the list at render, so a row drafted while the fetch was in
+ // flight opens checked rather than being removed by a stale selection.
+ const [flipped, setFlipped] = useState>(new Set())
const [candidateQuery, setCandidateQuery] = useState('')
const [expanded, setExpanded] = useState>(new Set())
// Capacities are edited as text, so a field's keystrokes are held here rather
@@ -194,19 +201,32 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const capacityText = (model: ModelDraft, index: number, field: CapacityField): string =>
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, field))
- /** Drop one row's entries and shift the rows after it down, in one pass. */
- const reindexOnRemove = (
- current: ReadonlyMap,
- index: number,
- ): Map => {
- const next = new Map()
- for (const [key, value] of current) {
- const at = Number(key.slice(0, key.indexOf(':')))
- if (at === index) continue
- // Only the row number moves; the field half of the key is untouched.
- next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value)
- }
- return next
+ /**
+ * Drop the removed rows' disclosure and buffer entries and shift the rows
+ * after them down, so per-row state follows its row.
+ */
+ const forgetRows = (removed: ReadonlySet): void => {
+ if (removed.size === 0) return
+ /** The surviving row's new position, or `undefined` for a removed row. */
+ const moved = (at: number): number | undefined =>
+ removed.has(at) ? undefined : at - [...removed].filter(gone => gone < at).length
+ setExpanded((current) => {
+ const next = new Set()
+ for (const at of current) {
+ const to = moved(at)
+ if (to !== undefined) next.add(to)
+ }
+ return next
+ })
+ setEditing((current) => {
+ const next = new Map()
+ for (const [key, value] of current) {
+ const to = moved(Number(key.slice(0, key.indexOf(':'))))
+ // Only the row number moves; the field half of the key is untouched.
+ if (to !== undefined) next.set(key.replace(/^\d+/, String(to)), value)
+ }
+ return next
+ })
}
const toggleExpanded = (index: number): void => {
@@ -254,12 +274,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
setFailure(t('fetchEmpty'))
return
}
- // Everything already configured starts unchecked, so adopting a
- // selection never silently rewrites a capacity the user corrected.
- const known = new Set(models.map(model => textOf(model, 'id')))
setCandidateQuery('')
- setCandidates(found)
- setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
+ setFlipped(new Set())
+ // A listing may repeat an id; the picker and the list hold each model once.
+ setCandidates([...new Map(found.map(model => [model.id, model])).values()])
} finally {
setBusy(false)
}
@@ -267,50 +285,68 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const closePicker = (): void => {
setCandidates(undefined)
- setPicked(new Set())
+ setFlipped(new Set())
setCandidateQuery('')
}
+ // The picker opens on the current list so a few models can be added or
+ // removed in one place. A list with no identified model serves the whole
+ // catalog or nothing yet, so every candidate starts checked.
+ const configuredIds = new Set(models.map(model => textOf(model, 'id')).filter(id => id.length > 0))
+ const pickedByDefault = (id: string): boolean => configuredIds.size === 0 || configuredIds.has(id)
+ const isPicked = (id: string): boolean => pickedByDefault(id) !== flipped.has(id)
+
+ const activeCandidates = candidates ?? []
+ const listed = new Set(activeCandidates.map(candidate => candidate.id))
+ // An unchecked candidate leaves the list. A row the listing does not name,
+ // including a half-typed row without an id, is outside the picker and stays.
+ const removedRows = new Set()
+ models.forEach((model, at) => {
+ const id = textOf(model, 'id')
+ if (listed.has(id) && !isPicked(id)) removedRows.add(at)
+ })
+ // A kept row the user already tuned wins over the provider's own numbers.
+ const keptRows = models.filter((_model, at) => !removedRows.has(at))
+ const keptIds = new Set(keptRows.map(model => textOf(model, 'id')))
+ const addedRows = activeCandidates
+ .filter(candidate => isPicked(candidate.id) && !keptIds.has(candidate.id))
+ .map(adopt)
+ // An empty list serves the whole catalog, so emptying a non-empty list
+ // would restore every model instead of removing the unchecked ones.
+ const emptiesList = models.length > 0 && keptRows.length === 0 && addedRows.length === 0
+
const adoptPicked = (): void => {
- /* v8 ignore next -- the dialog only renders with candidates loaded */
- if (candidates === undefined) return
- const byId = new Map(models.map(model => [textOf(model, 'id'), model]))
- for (const candidate of candidates) {
- if (!picked.has(candidate.id)) continue
- // A row the user already tuned wins over the provider's own numbers.
- // Keyed by id, so a half-typed row whose id is still empty is not a
- // match and the candidate joins as its own row — correct, since a row
- // without an id is not yet a model and the create/apply gates refuse it.
- byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate))
- }
- onChange([...byId.values()])
+ onChange([...keptRows, ...addedRows])
+ forgetRows(removedRows)
closePicker()
}
const toggle = (id: string): void => {
- setPicked((current) => {
+ setFlipped((current) => {
const next = new Set(current)
if (!next.delete(id)) next.add(id)
return next
})
}
- const activeCandidates = candidates ?? []
const normalizedCandidateQuery = candidateQuery.trim().toLowerCase()
const visibleCandidates = normalizedCandidateQuery.length === 0
? activeCandidates
: activeCandidates.filter(candidate => candidate.id.toLowerCase().includes(normalizedCandidateQuery)
|| candidate.name?.toLowerCase().includes(normalizedCandidateQuery) === true)
const allVisibleCandidatesPicked = visibleCandidates.length > 0
- && visibleCandidates.every(candidate => picked.has(candidate.id))
+ && visibleCandidates.every(candidate => isPicked(candidate.id))
const toggleVisibleCandidates = (): void => {
- setPicked((current) => {
- if (visibleCandidates.every(candidate => current.has(candidate.id))) {
- return new Set()
- }
+ // Only visible candidates change: a hidden checked one may be a configured
+ // model, and clearing it would remove that model unseen.
+ const target = !allVisibleCandidatesPicked
+ setFlipped((current) => {
const next = new Set(current)
- for (const candidate of visibleCandidates) next.add(candidate.id)
+ for (const candidate of visibleCandidates) {
+ if (pickedByDefault(candidate.id) === target) next.delete(candidate.id)
+ else next.add(candidate.id)
+ }
return next
})
}
@@ -384,15 +420,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
onToggle={() => { toggleExpanded(index) }}
onRemove={() => {
onChange(models.filter((_model, at) => at !== index))
- setExpanded((current) => {
- const next = new Set()
- for (const at of current) {
- if (at < index) next.add(at)
- else if (at > index) next.add(at - 1)
- }
- return next
- })
- setEditing(current => reindexOnRemove(current, index))
+ forgetRows(new Set([index]))
}}
/>
))}
@@ -417,7 +445,14 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
footer={(
<>
-
+
>
)}
>
@@ -448,7 +483,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {