Merge branch 'master' into worktree/forge-code-review-gaps-2d9d2e

This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-11-05 15:50:22 +08:00
commit 1e35b8ebc8
190 files changed
+3234 -1033

No files matched your search

@@ -18,8 +18,8 @@
en: 9d68ffca5c8be681
zh: 36bb661f98de0ebb
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution:
en: 32f59c35d71a4832
zh: e9288313bb18b78d
en: 15f6fb26c8605260
zh: 93467641a8ff0aad
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections:
en: 5fcd3319fbceed1d
zh: 20771646b9c36fa6
@@ -27,11 +27,11 @@
en: 2c7c84f8fa1e8745
zh: 5d0244e4f17ba12a
/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered:
en: 0c227e270894cd71
zh: 114484a3e69f5c0a
en: 2ec7f019ec3ce309
zh: a6a5a8db1f1dd682
/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences:
en: 50f046bf81a7783d
zh: 5c0e27bdcaf9f12e
en: ea4d3f05e8be197a
zh: 81faeb3edf122869
/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification:
en: 26ade0695094e9bc
zh: 7b265c43b1886da5
en: 0db673b16e4aeda3
zh: 3b086edecebdc5b5
@@ -28,11 +28,11 @@ The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolv
### Credential resolution
Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan.
Each of Search and Fetch has a billing setting, `web-deepseek-api.searchBilling` or `fetchBilling`, which the settings page edits as a **Billing** menu under the selected API. The default, `auto`, follows the DeepSeek credential of the conversation; `account` sends only the signed-in account's token and `api-key` only the API key, without falling back to the other. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan.
On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint.
The account token is the one `ctx.deepseekAccount` releases for the dispatch endpoint under the origin of the active `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, with `/v1/tools`; without that entry, the origin of that route's default base URL is used. An HTTP 401 invalidates that token. With `auto` billing on the `deepseek-account` route, the operation sends the account token when that entry exists and a token is released. Every other `auto` operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. With `auto` billing on a route other than the two DeepSeek routes, or outside a Session, an operation without a configured key sends the account token instead, so a signed-in user without a key keeps web tools in conversations on other model providers; on the `deepseek-official` route it never sends the token, because that conversation bills the API key. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing credential makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance for its billing that names the account sign-in, the Models page and the reference, and the billing field. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` before reading the credential that depends on that route, without trying later sources or sending the request, so a broken gateway setting never redirects requests to the official endpoint.
A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect.
A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead, whatever its billing, always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect.
The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key.
@@ -66,7 +66,7 @@ The Messages provider owns and writes `web/deepseek-search-llm-request`. The dec
**Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine.
**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider.
**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. The default `auto` billing follows the conversation's credential without configuration, the billing menu offers only the two DeepSeek credentials, and the YAML-only own fields cover endpoints that differ from the model provider.
**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways.
@@ -76,10 +76,10 @@ The Messages provider owns and writes `web/deepseek-search-llm-request`. The dec
## Consequences
Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup.
Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search with `auto` billing in every conversation except those on the DeepSeek API-key route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup.
A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it.
## Verification
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps.
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, the account fallback without a key outside the DeepSeek routes, account-only and API-key-only billing on every route, own fields over billing, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the billing menus of the DeepSeek APIs, and the settings each provider keeps.
@@ -28,11 +28,11 @@ Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解
### 凭据解析
Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。
Search 与 Fetch 各有一个计费方式设置 `web-deepseek-api.searchBilling` 或 `fetchBilling`,设置页在选中的 API 下方以**计费方式**下拉框编辑它。默认值 `auto` 跟随会话的 DeepSeek 凭据;`account` 只发送已登录账号的令牌,`api-key` 只发送 API 密钥,都不回退到另一种凭据。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。
在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。
账号令牌是 `ctx.deepseekAccount` 为模型提供方目录列出的入口根部活动 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目的 origin 加 `/v1/tools` 下的请求端点给出的令牌;没有该条目时使用该路由默认基础地址的 origin。HTTP 401 会作废该令牌。`auto` 计费在 `deepseek-account` 路由上,若存在该条目且取得令牌,操作发送账号令牌。其他所有 `auto` 操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。`auto` 计费在两个 DeepSeek 路由之外的路由上或会话之外,没有配置密钥的操作改为发送账号令牌,因此已登录但没有密钥的用户在其他模型提供方的会话中仍可使用网页工具;在 `deepseek-official` 路由上从不发送令牌,因为该会话按 API 密钥计费。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少凭据时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息按计费方式点名账号登录、模型页面与该引用,以及计费方式字段。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并在读取依赖该路由的凭据前以 `WEB_PROVIDER_MISCONFIGURED` 失败,不再尝试后续来源,也不发送请求,因此网关设置出错时请求不会被改发到官方端点。
在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。
在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则无论计费方式如何,始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。
`@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。
@@ -66,7 +66,7 @@ Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声
**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。
**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。
**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。默认的 `auto` 计费无需配置即跟随会话的凭据,计费方式下拉框只提供两种 DeepSeek 凭据,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。
**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。
@@ -76,10 +76,10 @@ Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声
## 影响
没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。
没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户在 `auto` 计费下,除 DeepSeek API 密钥路由上的会话外,都可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。
插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。
## 验证
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、DeepSeek 路由之外没有密钥时回退到账号、在每个路由上只用账号或只用 API 密钥的计费、单独字段优先于计费方式、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 的计费方式下拉框,以及各提供方保留的设置。
+2 -2
View File
@@ -54,8 +54,8 @@
en: ac31ebdd9de54f34
zh: 921e534d5c4b3a16
/deepseek-harness-desktop/windows-installer-interface:
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
en: f1302ed4a3ab58bb
zh: cb53b9b349815028
/deepseek-harness-desktop/updates:
en: 67bf0a18faa7aa76
zh: 782e14ce62bb939f
+3 -1
View File
@@ -250,13 +250,15 @@ Use the dedicated guides for build and release operations. Shared Windows runner
## Windows installer interface
During a confirmed Windows update restart, a separate process displays a draggable status notice until the installation stage ends. The installer does not wait for this window; installation and relaunch continue if it cannot start or stops responding. A gap can remain before the new application window appears. Optional DPI APIs are resolved at runtime, with 96 DPI layout when unavailable.
The Windows installer uses native NSIS pages with light and dark palettes, system shadows, an editable installation directory, and a finish page whose launch checkbox is selected by default. Installation is restricted to the current user. Clicking Install or pressing Enter validates the current path; drive roots remain invalid after normalization, including repeated trailing separators. New destinations must be empty, and nonempty destinations must be registered installations. Running executables at the affected installation path produce a native prompt and remain running; same-named applications in other directories do not block installation. Silent updates wait up to ten seconds for the affected application to exit, then stop with exit code 2 if it is still running.
The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation.
Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include restart-notice process cleanup, upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
+3 -1
View File
@@ -250,13 +250,15 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
## Windows 安装界面
Windows 更新确认重启后,独立进程显示可拖动的状态提示,直到安装阶段结束。安装器不等待提示窗;提示窗无法启动或失去响应时,安装和重新启动仍会继续。新版应用窗口出现前仍可能有一段空白。可选的 DPI API 在运行时解析,不可用时采用 96 DPI 布局。
Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影、可编辑的安装目录,以及默认勾选立即启动的完成页。安装仅面向当前用户。点击安装或按 Enter 均校验当前路径;路径规范化后仍拒绝磁盘根目录,包括带重复末尾分隔符的写法。新安装位置必须为空,非空位置必须是已登记的安装目录。受影响安装路径中的程序运行时显示系统提示,并保持应用运行;其他目录中的同名应用不阻止安装。静默更新最多等待受影响应用退出十秒,若仍在运行则以退出码 2 结束。
主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含重启提示窗的进程回收、末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
@@ -1,6 +1,7 @@
{
"nsisLanguage": "GERMAN",
"strings": {
"INSTALLER_UPDATE_WAIT": "Das Update wird installiert. Anschließend startet die App automatisch neu.",
"INSTALLER_INSTALL": "Jetzt installieren",
"INSTALLER_FINISH": "Fertigstellen",
"INSTALLER_NATIVE_PROGRESS": "Installation wird vorbereitet… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "ENGLISH",
"strings": {
"INSTALLER_UPDATE_WAIT": "Installing the update. The app will restart automatically when the update is complete.",
"INSTALLER_INSTALL": "Install now",
"INSTALLER_FINISH": "Finish",
"INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "SPANISHINTERNATIONAL",
"strings": {
"INSTALLER_UPDATE_WAIT": "Instalando la actualización. La aplicación se reiniciará automáticamente al terminar.",
"INSTALLER_INSTALL": "Instalar ahora",
"INSTALLER_FINISH": "Finalizar",
"INSTALLER_NATIVE_PROGRESS": "Preparando la instalación… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "FRENCH",
"strings": {
"INSTALLER_UPDATE_WAIT": "Installation de la mise à jour en cours. L’application redémarrera automatiquement.",
"INSTALLER_INSTALL": "Installer maintenant",
"INSTALLER_FINISH": "Terminer",
"INSTALLER_NATIVE_PROGRESS": "Préparation de l'installation… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "JAPANESE",
"strings": {
"INSTALLER_UPDATE_WAIT": "アップデートをインストールしています。完了するとアプリが自動的に再起動します。",
"INSTALLER_INSTALL": "今すぐインストール",
"INSTALLER_FINISH": "完了",
"INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "PORTUGUESEBR",
"strings": {
"INSTALLER_UPDATE_WAIT": "Instalando a atualização. O aplicativo será reiniciado automaticamente ao concluir.",
"INSTALLER_INSTALL": "Instale agora",
"INSTALLER_FINISH": "Concluir",
"INSTALLER_NATIVE_PROGRESS": "Preparando a instalação… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "RUSSIAN",
"strings": {
"INSTALLER_UPDATE_WAIT": "Идёт установка обновления. После установки приложение перезапустится автоматически.",
"INSTALLER_INSTALL": "Установить сейчас",
"INSTALLER_FINISH": "Готово",
"INSTALLER_NATIVE_PROGRESS": "Подготовка установки… %d%%",
@@ -1,6 +1,7 @@
{
"nsisLanguage": "SIMPCHINESE",
"strings": {
"INSTALLER_UPDATE_WAIT": "正在安装更新,更新完成后将自动重启应用。",
"INSTALLER_INSTALL": "立即安装",
"INSTALLER_FINISH": "完成",
"INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%",
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include <algorithm>
#include <new>
#include "restart-text.h"
namespace restart_banner {
constexpr wchar_t stateKey[] = L"HarnessInstaller.RestartBanner";
constexpr int iconId = 0xffff, titleId = 76, bodyId = 1030;
struct Style {
DLGPROC previous;
HBRUSH brush;
HFONT font;
HFONT titleFont;
COLORREF background;
COLORREF foreground;
HBITMAP bitmap;
};
inline INT_PTR CALLBACK DialogProc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
auto* state = static_cast<Style*>(GetPropW(window, stateKey));
if (message == WM_NCCALCSIZE && wp) {
SetWindowLongPtrW(window, DWLP_MSGRESULT, 0);
return TRUE;
}
// Static labels pass pointer hits to the dialog. Let Windows own the move loop.
if (message == WM_NCHITTEST) {
SetWindowLongPtrW(window, DWLP_MSGRESULT, HTCAPTION);
return TRUE;
}
if (message == WM_PAINT || message == WM_PRINTCLIENT) {
PAINTSTRUCT paint{};
HDC dc = message == WM_PAINT ? BeginPaint(window, &paint) : reinterpret_cast<HDC>(wp);
RECT bounds{};
GetClientRect(window, &bounds);
FillRect(dc, &bounds, state->brush);
if (message == WM_PAINT) EndPaint(window, &paint);
return TRUE;
}
if (message == WM_CTLCOLORDLG || message == WM_CTLCOLORSTATIC) {
const auto dc = reinterpret_cast<HDC>(wp);
SetTextColor(dc, state->foreground);
SetBkColor(dc, state->background);
return reinterpret_cast<INT_PTR>(state->brush);
}
auto result = state->previous(window, message, wp, lp);
if (message == WM_NCDESTROY) {
RemovePropW(window, stateKey);
DeleteObject(state->font);
DeleteObject(state->brush);
DeleteObject(state->titleFont);
DeleteObject(state->bitmap);
delete state;
}
return result;
}
}
// Size the localized paragraph with the font and wrapping used by its static control.
static void InstallerStyleRestartBanner(HWND window, LPCWSTR text,
LPCWSTR locale, LPCWSTR face, int logicalFontSize, COLORREF background, COLORREF foreground) {
const HWND label = GetDlgItem(window, restart_banner::bodyId);
const HWND icon = GetDlgItem(window, restart_banner::iconId);
if (!label || !icon) return;
const HMODULE user = GetModuleHandleW(L"user32.dll");
#pragma warning(push)
#pragma warning(disable: 4191)
const auto getDpi = reinterpret_cast<decltype(&GetDpiForWindow)>(GetProcAddress(user, "GetDpiForWindow"));
#pragma warning(pop)
const UINT dpi = getDpi ? getDpi(window) : 96;
const auto px = [dpi](int value) { return MulDiv(value, dpi, 96); };
HMODULE module;
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
reinterpret_cast<LPCWSTR>(&restart_banner::DialogProc), &module)) return;
auto* state = new (std::nothrow) restart_banner::Style{
reinterpret_cast<DLGPROC>(GetWindowLongPtrW(window, DWLP_DLGPROC)), CreateSolidBrush(background),
CreateFontW(-px(logicalFontSize), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET,
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, face), CreateFontW(-px(16), 0, 0, 0, FW_SEMIBOLD, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, face), background, foreground, nullptr};
if (!state) return;
if (!state->brush || !state->titleFont || !state->font || !SetPropW(window, restart_banner::stateKey, state)) {
DeleteObject(state->font);
DeleteObject(state->brush);
DeleteObject(state->titleFont);
delete state;
return;
}
SetWindowLongPtrW(window, DWLP_DLGPROC, reinterpret_cast<LONG_PTR>(&restart_banner::DialogProc));
const int padding = px(20), gap = px(12), iconSize = px(32), width = px(388);
RECT paragraph{0, 0, width - 2 * padding - gap - iconSize, 0};
HDC dc = GetDC(window);
HGDIOBJ previous = SelectObject(dc, state->font);
DrawTextW(dc, text, -1, &paragraph, DT_CALCRECT | DT_WORDBREAK | DT_NOPREFIX | DT_EDITCONTROL);
SelectObject(dc, previous);
ReleaseDC(window, dc);
const int bodyWidth = width - 2 * padding - gap - iconSize;
HBITMAP bitmap = RenderRestartParagraph(window, text, face, locale, logicalFontSize, bodyWidth, dpi, background, foreground, paragraph.bottom);
state->bitmap = bitmap;
const int titleHeight = px(22), titleGap = px(4);
const int textHeight = titleHeight + titleGap + paragraph.bottom;
const int height = 2 * padding + std::max<int>(iconSize, textHeight);
// The stock Banner dialog has a raised system edge outside the themed client area.
SetWindowLongPtrW(window, GWL_STYLE, GetWindowLongPtrW(window, GWL_STYLE) & ~DS_MODALFRAME);
SetWindowLongPtrW(window, GWL_EXSTYLE, GetWindowLongPtrW(window, GWL_EXSTYLE) &
~(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE));
const BOOL dark = background != RGB(255, 255, 255);
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
ApplyInstallerDwmFrame(window);
RECT client{}, outer{};
GetClientRect(window, &client);
GetWindowRect(window, &outer);
const int newWidth = width + outer.right - outer.left - client.right;
const int newHeight = height + outer.bottom - outer.top - client.bottom;
SetWindowPos(window, nullptr, (outer.left + outer.right - newWidth) / 2,
(outer.top + outer.bottom - newHeight) / 2, newWidth, newHeight, SWP_NOACTIVATE | SWP_NOZORDER);
const HWND title = GetDlgItem(window, restart_banner::titleId);
SetWindowLongPtrW(title, GWL_STYLE, (GetWindowLongPtrW(title, GWL_STYLE) & ~SS_TYPEMASK) | SS_LEFT | SS_NOPREFIX);
SetWindowTextW(title, L"DeepSeek Harness");
SendMessageW(title, WM_SETFONT, reinterpret_cast<WPARAM>(state->titleFont), FALSE);
SetWindowPos(title, nullptr, padding + iconSize + gap, (height - textHeight) / 2,
width - 2 * padding - gap - iconSize, titleHeight, SWP_NOZORDER | SWP_SHOWWINDOW);
const LONG_PTR style = GetWindowLongPtrW(label, GWL_STYLE);
SetWindowLongPtrW(label, GWL_STYLE, (style & ~SS_TYPEMASK) | SS_LEFT | SS_NOPREFIX | SS_EDITCONTROL);
SendMessageW(label, WM_SETFONT, reinterpret_cast<WPARAM>(state->font), FALSE);
SetWindowPos(icon, nullptr, padding, (height - iconSize) / 2, iconSize, iconSize, SWP_NOZORDER);
SetWindowPos(label, nullptr, padding + iconSize + gap, (height - textHeight) / 2 + titleHeight + titleGap,
width - 2 * padding - gap - iconSize, paragraph.bottom, SWP_NOZORDER);
if (bitmap) {
SetWindowLongPtrW(label, GWL_STYLE, (GetWindowLongPtrW(label, GWL_STYLE) & ~SS_TYPEMASK) | SS_BITMAP);
SendMessageW(label, STM_SETIMAGE, IMAGE_BITMAP, reinterpret_cast<LPARAM>(bitmap));
}
RedrawWindow(window, nullptr, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_UPDATENOW);
}
+147
View File
@@ -0,0 +1,147 @@
#pragma once
#include <shellapi.h>
#include <memory>
#include <string>
#include "restart-banner.h"
namespace restart_status {
struct Launch {
std::wstring command;
std::wstring program;
HANDLE job;
};
// Process creation can involve security software. The installer never joins this thread.
static DWORD WINAPI LaunchWorker(void* data) {
std::unique_ptr<Launch> launch(static_cast<Launch*>(data));
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SIZE_T bytes = 0;
InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes);
auto* attributes = static_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(HeapAlloc(GetProcessHeap(), 0, bytes));
bool started = false;
if (attributes && SetInformationJobObject(launch->job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) &&
InitializeProcThreadAttributeList(attributes, 1, 0, &bytes)) {
if (UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &launch->job, sizeof(HANDLE), nullptr, nullptr)) {
STARTUPINFOEXW startup{};
startup.StartupInfo.cb = sizeof(startup);
startup.lpAttributeList = attributes;
PROCESS_INFORMATION process{};
started = CreateProcessW(launch->program.c_str(), &launch->command[0], nullptr, nullptr, FALSE,
EXTENDED_STARTUPINFO_PRESENT, nullptr, nullptr, &startup.StartupInfo, &process) != FALSE;
if (started) {
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
}
DeleteProcThreadAttributeList(attributes);
}
if (attributes) HeapFree(GetProcessHeap(), 0, attributes);
CloseHandle(launch->job);
return 0;
}
static INT_PTR CALLBACK Dialog(HWND window, UINT message, WPARAM, LPARAM) {
if (message == WM_CLOSE) { DestroyWindow(window); return TRUE; }
if (message == WM_DESTROY) { PostQuitMessage(0); return TRUE; }
return FALSE;
}
}
// The shell host is a member of a private job from its first instruction. Parent exit
// terminates even a stuck window, without a child-to-installer IPC or acknowledgment.
extern "C" __declspec(dllexport) HANDLE __cdecl InstallerStartRestartStatus(LPCWSTR installer, LPCWSTR theme,
int language, LPCWSTR text) noexcept {
try {
HMODULE module{};
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
reinterpret_cast<LPCWSTR>(&restart_status::LaunchWorker), &module)) return nullptr;
wchar_t dll[MAX_PATH]{}, system[MAX_PATH]{};
const DWORD length = GetModuleFileNameW(module, dll, ARRAYSIZE(dll));
const UINT sysLength = GetSystemDirectoryW(system, ARRAYSIZE(system));
if (!length || length >= ARRAYSIZE(dll) || !sysLength || sysLength >= ARRAYSIZE(system)) return nullptr;
auto launch = std::make_unique<restart_status::Launch>();
launch->program = std::wstring(system) + L"\\rundll32.exe";
launch->command = L"\"" + launch->program + L"\" \"" + dll + L"\",ShowRestartStatus \"" +
installer + L"\" " + std::to_wstring(language) + L" " + theme + L" " + text;
HANDLE owner = CreateJobObjectW(nullptr, nullptr);
if (!owner) return nullptr;
if (!DuplicateHandle(GetCurrentProcess(), owner, GetCurrentProcess(), &launch->job, 0, FALSE, DUPLICATE_SAME_ACCESS)) {
CloseHandle(owner);
return nullptr;
}
auto* args = launch.release();
HANDLE thread = CreateThread(nullptr, 0, restart_status::LaunchWorker, args, 0, nullptr);
if (thread) {
CloseHandle(thread);
return owner;
}
CloseHandle(owner);
CloseHandle(args->job);
delete args;
} catch (const std::bad_alloc&) {
// A missing status window does not change the installation.
}
return nullptr;
}
#if defined(_M_IX86)
#pragma comment(linker, "/EXPORT:ShowRestartStatusW=_ShowRestartStatusW@16")
#else
#pragma comment(linker, "/EXPORT:ShowRestartStatusW")
#endif
extern "C" void CALLBACK ShowRestartStatusW(HWND, HINSTANCE instance, LPWSTR raw, int) {
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
if (!raw || *raw != L'"') return;
wchar_t* end = wcschr(raw + 1, L'"');
if (!end || end[1] != L' ') return;
*end = 0;
const wchar_t* installer = raw + 1;
wchar_t* theme{};
const long language = wcstol(end + 2, &theme, 10);
if (*theme != L' ') return;
wchar_t* text = wcschr(++theme, L' ');
if (!text) return;
*text++ = 0;
const bool dark = wcscmp(theme, L"dark") == 0;
wchar_t locale[LOCALE_NAME_MAX_LENGTH]{};
LCIDToLocaleName(MAKELCID(language, SORT_DEFAULT), locale, ARRAYSIZE(locale), 0);
#pragma warning(push)
#pragma warning(disable: 4191)
const auto setDpi = reinterpret_cast<decltype(&SetThreadDpiAwarenessContext)>(
GetProcAddress(GetModuleHandleW(L"user32.dll"), "SetThreadDpiAwarenessContext"));
#pragma warning(pop)
if (setDpi) setDpi(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
struct { DLGTEMPLATE dialog; WORD menu, cls, title; } layout{};
layout.dialog.style = WS_POPUP | DS_CENTER | DS_MODALFRAME;
layout.dialog.dwExtendedStyle = WS_EX_APPWINDOW;
layout.dialog.cx = 180;
layout.dialog.cy = 50;
HWND window = CreateDialogIndirectParamW(instance, &layout.dialog, nullptr, restart_status::Dialog, 0);
if (!window) return;
SetWindowTextW(window, L"DeepSeek Harness");
const DWORD style = WS_CHILD | WS_VISIBLE;
HWND image = CreateWindowW(L"STATIC", L"", style | SS_ICON, 0,0,0,0, window,
reinterpret_cast<HMENU>(restart_banner::iconId), instance, nullptr);
HWND title = CreateWindowW(L"STATIC", L"", style, 0,0,0,0, window, reinterpret_cast<HMENU>(restart_banner::titleId), instance, nullptr);
HWND label = CreateWindowW(L"STATIC", text, style, 0,0,0,0, window, reinterpret_cast<HMENU>(restart_banner::bodyId), instance, nullptr);
if (!image || !title || !label) { DestroyWindow(window); return; }
HICON large{}, smallIcon{};
ExtractIconExW(installer, 0, &large, &smallIcon, 1);
SendMessageW(image, STM_SETICON, reinterpret_cast<WPARAM>(large), 0);
SendMessageW(window, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(large));
SendMessageW(window, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(smallIcon));
InstallerStyleRestartBanner(window, text, locale, L"Microsoft YaHei UI", 14,
dark ? RGB(21,21,23) : RGB(255,255,255), dark ? RGB(255,255,255) : RGB(15,17,21));
if (GetPropW(window, restart_banner::stateKey)) {
ShowWindow(window, SW_SHOWNOACTIVATE);
MSG message{};
while (GetMessageW(&message, nullptr, 0, 0) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
if (IsWindow(window)) DestroyWindow(window);
DestroyIcon(large);
DestroyIcon(smallIcon);
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <d2d1.h>
#include <dwrite.h>
#include <wrl/client.h>
#include <cmath>
// Optional graphics libraries are loaded only in the status process. Keep the GDI dimensions
// until the complete DirectWrite bitmap is available, including a successful EndDraw.
static HBITMAP RenderRestartParagraph(HWND window, LPCWSTR text, LPCWSTR face, LPCWSTR locale,
int logicalSize, int width, UINT dpi, COLORREF background, COLORREF foreground, LONG& height) {
using Microsoft::WRL::ComPtr;
HMODULE write = LoadLibraryExW(L"dwrite.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
HMODULE draw = LoadLibraryExW(L"d2d1.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32);
if (!write || !draw) return nullptr;
// Both libraries stay loaded for the lifetime of this single-window process.
#pragma warning(push)
#pragma warning(disable: 4191)
auto createWrite = reinterpret_cast<decltype(&DWriteCreateFactory)>(GetProcAddress(write, "DWriteCreateFactory"));
using CreateDraw = HRESULT(WINAPI*)(D2D1_FACTORY_TYPE, REFIID, const D2D1_FACTORY_OPTIONS*, void**);
auto createDraw = reinterpret_cast<CreateDraw>(GetProcAddress(draw, "D2D1CreateFactory"));
#pragma warning(pop)
if (!createWrite || !createDraw) return nullptr;
ComPtr<IDWriteFactory> factory;
ComPtr<IDWriteTextFormat> format;
ComPtr<IDWriteTextLayout> layout;
ComPtr<ID2D1Factory> graphics;
ComPtr<ID2D1DCRenderTarget> target;
ComPtr<ID2D1SolidColorBrush> brush;
const float scale = dpi / 96.0f;
if (FAILED(createWrite(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), reinterpret_cast<IUnknown**>(factory.GetAddressOf()))) ||
FAILED(factory->CreateTextFormat(face, nullptr, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL, static_cast<float>(logicalSize), locale, &format)) ||
FAILED(format->SetWordWrapping(DWRITE_WORD_WRAPPING_EMERGENCY_BREAK)) ||
FAILED(factory->CreateTextLayout(text, static_cast<UINT32>(wcslen(text)), format.Get(), width / scale, 4096, &layout)) ||
FAILED(createDraw(D2D1_FACTORY_TYPE_SINGLE_THREADED, __uuidof(ID2D1Factory), nullptr, reinterpret_cast<void**>(graphics.GetAddressOf())))) return nullptr;
DWRITE_TEXT_METRICS metrics{};
if (FAILED(layout->GetMetrics(&metrics))) return nullptr;
const LONG bitmapHeight = static_cast<LONG>(std::ceil(metrics.height * scale));
const auto props = D2D1::RenderTargetProperties(D2D1_RENDER_TARGET_TYPE_SOFTWARE,
D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_IGNORE), static_cast<float>(dpi), static_cast<float>(dpi));
if (FAILED(graphics->CreateDCRenderTarget(&props, &target))) return nullptr;
HDC screen = GetDC(window);
if (!screen) return nullptr;
HDC dc = CreateCompatibleDC(screen);
HBITMAP bitmap = CreateCompatibleBitmap(screen, width, bitmapHeight);
ReleaseDC(window, screen);
const auto color = [](COLORREF c) { return D2D1::ColorF(GetRValue(c)/255.f, GetGValue(c)/255.f, GetBValue(c)/255.f); };
const RECT bounds{0, 0, width, bitmapHeight};
HGDIOBJ old = dc && bitmap ? SelectObject(dc, bitmap) : nullptr;
bool painted = false;
if (old && SUCCEEDED(target->BindDC(dc, &bounds)) && SUCCEEDED(target->CreateSolidColorBrush(color(foreground), &brush))) {
target->BeginDraw();
target->Clear(color(background));
target->DrawTextLayout(D2D1::Point2F(), layout.Get(), brush.Get());
painted = SUCCEEDED(target->EndDraw());
}
if (old) SelectObject(dc, old);
if (dc) DeleteDC(dc);
if (!painted) { DeleteObject(bitmap); return nullptr; }
height = bitmapHeight;
return bitmap;
}
+19 -13
View File
@@ -10,6 +10,24 @@
#include <gdiplus.h>
#include <new>
#include <algorithm>
static HRESULT ApplyInstallerDwmFrame(HWND window) {
// DWM retains native transitions; WM_NCCALCSIZE removes the visible caption.
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION);
const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
if (FAILED(result)) return result;
const DWORD rounded = 2;
// Windows 10 does not support the Windows 11 corner preference.
DwmSetWindowAttribute(window, 33, &rounded, sizeof(rounded));
const MARGINS margins = {1, 1, 1, 1};
result = DwmExtendFrameIntoClientArea(window, &margins);
SetWindowPos(window, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
return result;
}
#include "restart-banner.h"
#include "restart-status.h"
#include "progress.h"
#include "extract.h"
#include "extract-report.h"
@@ -419,19 +437,7 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window
ShowWindow(child, SW_HIDE);
}
}
// DWM retains native transitions; WM_NCCALCSIZE removes the visible caption.
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION);
const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
if (FAILED(result)) return result;
const DWORD rounded = 2;
// Windows 10 does not support the Windows 11 corner preference.
DwmSetWindowAttribute(window, 33, &rounded, sizeof(rounded));
const MARGINS margins = {1, 1, 1, 1};
result = DwmExtendFrameIntoClientArea(window, &margins);
SetWindowPos(window, nullptr, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
return result;
return ApplyInstallerDwmFrame(window);
}
// Present the first interactive page after resource preparation without keeping the installer topmost.
+1 -1
View File
@@ -87,7 +87,7 @@
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"@deepseek-ai/dsh-client-ui-file-actions": "workspace:*",
"tencentcloud-sdk-nodejs-teo": "4.1.330"
+11
View File
@@ -1,4 +1,5 @@
!include "LogicLib.nsh"
!include "${__FILEDIR__}\restart-banner.nsh"
!include "FileFunc.nsh"
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
@@ -66,6 +67,11 @@ ManifestDPIAware true
File "/oname=$PLUGINSDIR\brand-dark.bmp" "${INSTALLER_BUILD_DIR}\brand-dark.bmp"
File "/oname=$PLUGINSDIR\brand-dark-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-dark-2x.bmp"
File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
${If} ${Silent}
${AndIf} ${isUpdated}
${AndIf} ${isForceRun}
!insertmacro ShowRestartBanner
${EndIf}
Call InstallerCheckAppRunning
${If} ${Silent}
Call InstallerPreflight
@@ -238,6 +244,11 @@ ManifestDPIAware true
${EndIf}
!insertmacro InstallerPublishStage 4
!insertmacro dshFinishDirectories
${If} $RestartStatusJob != ""
${AndIf} $RestartStatusJob != 0
System::Call 'kernel32::CloseHandle(p $RestartStatusJob)'
StrCpy $RestartStatusJob 0
${EndIf}
; Standard uninstall-entry metadata read by inventory tools; the upstream template records it only under its private key.
WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" InstallLocation "$INSTDIR"
${If} $0 == 1
+26
View File
@@ -0,0 +1,26 @@
!ifndef BUILD_UNINSTALLER
Var RestartStatusJob
!endif
!macro ShowRestartBanner
Push $0
${If} ${Errors}
StrCpy $0 1
${Else}
StrCpy $0 0
${EndIf}
Push $1
Push $2
StrCpy $1 0
StrCpy $2 "$(INSTALLER_UPDATE_WAIT)"
System::Call '$PLUGINSDIR\window-frame.dll::InstallerStartRestartStatus(w "$EXEPATH", w "$InstallerTheme", i $LANGUAGE, w r2) p.r1 ?c'
StrCpy $RestartStatusJob $1
Pop $2
Pop $1
${If} $0 == 1
SetErrors
${Else}
ClearErrors
${EndIf}
Pop $0
!macroend
@@ -108,6 +108,17 @@ SectionEnd
const compiler = await getMakeNsisPath()
await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false })
if (!uninstallOnly && !process.argv.includes('--compile-only')) {
const owner = join(output, 'restart-status-owner.exe')
await execute(compiler.path, ['/V2', `/DOWNER_EXE=${owner}`,
`/DBANNER_INCLUDE=${join(appRoot, 'scripts', 'restart-banner.nsh')}`,
`/DSTATUS_DLL=${join(output, 'ui', 'window-frame.dll')}`,
join(appRoot, 'tests', 'fixtures', 'restart-status-owner.nsi')],
{ ...childOptions, env: { ...childOptions.env, ...compiler.env } })
if (sign) await sign({ path: owner, hash: 'sha256', isNest: false })
await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
join(appRoot, 'tests', 'windows-restart-status.ps1'), '-OwnerExe', owner], childOptions)
}
for (const locale of readInstallerLocales()) {
const language = locale.id
const config = createElectronBuilderConfig()
+31
View File
@@ -0,0 +1,31 @@
; A private owner lets the test interrupt either process without touching an installation.
Unicode true
RequestExecutionLevel user
SilentInstall silent
OutFile "${OWNER_EXE}"
!include "LogicLib.nsh"
!include "${BANNER_INCLUDE}"
LoadLanguageFile "${NSISDIR}\Contrib\Language files\English.nlf"
LangString INSTALLER_UPDATE_WAIT ${LANG_ENGLISH} "Installing the $\"update$\". It's almost ready."
Var InstallerTheme
Section
InitPluginsDir
File "/oname=$PLUGINSDIR\window-frame.dll" "${STATUS_DLL}"
StrCpy $InstallerTheme "dark"
SetErrors
!insertmacro ShowRestartBanner
IfErrors +3
SetErrorLevel 3
Quit
StrCpy $0 0
wait:
IfFileExists "$EXEDIR\finish-owner" done
Sleep 50
IntOp $0 $0 + 1
IntCmp $0 400 timeout wait timeout
timeout:
SetErrorLevel 4
Quit
done:
SetErrorLevel 0
SectionEnd
+2 -1
View File
@@ -23,7 +23,8 @@ it('generates Japanese NSIS strings from the same files that select packaged lan
expect(source).toContain('LangString INSTALLER_INSTALL ${LANG_JAPANESE} "今すぐインストール"')
expect(source).toContain('LangString INSTALLER_NATIVE_PROGRESS ${LANG_JAPANESE} "インストールを準備しています… %d%%"')
expect(source).toContain('!ifdef LANG_JAPANESE\n')
expect(source.match(/^LangString /gmu)).toHaveLength(248)
expect(source.match(/^LangString INSTALLER_UPDATE_WAIT /gmu)).toHaveLength(8)
expect(source.match(/^LangString /gmu)).toHaveLength(256)
const japanese = locales.filter(locale => locale.id === 'ja_JP')
expect(renderInstallerStrings(japanese)).not.toContain('${LANG_ENGLISH}')
})
@@ -0,0 +1,32 @@
<# The private NSIS owner must finish after its window dies, and reap the window on owner death. #>
param([Parameter(Mandatory)][string]$OwnerExe)
$ErrorActionPreference = 'Stop'
$marker = Join-Path (Split-Path $OwnerExe) 'finish-owner'
foreach ($interrupt in 'window', 'owner') {
$owner = Start-Process -FilePath $OwnerExe -WindowStyle Hidden -PassThru
$window = $null
try {
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
$row = Get-CimInstance Win32_Process -Filter ("ParentProcessId={0} AND Name='rundll32.exe'" -f $owner.Id)
if ($row) { $window = Get-Process -Id $row.ProcessId -ErrorAction SilentlyContinue }
if ($window -and $window.MainWindowHandle -ne 0) { break }
if ($owner.HasExited -or $timer.Elapsed.TotalSeconds -gt 12) { throw 'No status window under the private owner' }
Start-Sleep -Milliseconds 40
} while ($true)
if ($interrupt -eq 'window') {
$window.Kill()
New-Item -ItemType File -Path $marker | Out-Null
if (-not $owner.WaitForExit(5000) -or $owner.ExitCode -ne 0) { throw 'Window failure changed owner completion' }
} else {
$owner.Kill()
}
if (-not $window.WaitForExit(5000)) { throw 'The status window survived its owner' }
} finally {
if (-not $owner.HasExited) { $owner.Kill() }
if ($window -and -not $window.HasExited) { $window.Kill() }
if (Test-Path -LiteralPath $marker) { Remove-Item -LiteralPath $marker }
$owner.Dispose()
if ($window) { $window.Dispose() }
}
}
@@ -4,7 +4,7 @@
- region "网页搜索提供方":
- heading "网页搜索提供方" [level=4]
- button "网页搜索提供方 DeepSeek Search API": DeepSeek Search API
- paragraph: 直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费
- paragraph: 会产生费用,由「计费方式」选定的账号或 API Key 支付。
- paragraph:
- link "网页搜索与获取指南":
- /url: https://api-docs.deepseek.com/zh-cn/guides/search
@@ -12,9 +12,15 @@
- textbox "每次搜索结果数":
- /placeholder: ""
- text: "10"
- paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。
- paragraph: 每次搜索返回的结果条数,1–50,默认 10。
- text: 计费方式
- button "计费方式 自动": 自动
- paragraph: 跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。
- region "网页获取提供方":
- heading "网页获取提供方" [level=4]
- button "网页获取提供方 DeepSeek Fetch API": DeepSeek Fetch API
- paragraph: 由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机
- paragraph: 无法访问内网和本机。会产生费用,由「计费方式」选定的账号或 API Key 支付。
- text: 计费方式
- button "计费方式 自动": 自动
- paragraph: 跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。
- button "保存" [disabled]
+15 -6
View File
@@ -182,14 +182,15 @@ describe('web e2e: plugin configuration pages', () => {
expect(await panel.getByRole('link', { name: '网页搜索与获取指南', exact: true }).getAttribute('href'))
.toBe('https://api-docs.deepseek.com/zh-cn/guides/search')
// Each section shows the description its selected provider registered with the Host, in the active language.
expect(await searchSection.getByText('直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', { exact: true }).count()).toBe(1)
expect(await fetchSection.getByText('本机直接请求,免 API Key 与计费,可访问内网和本机。仅解码 HTML、JSON 等文本,不执行 JavaScript、不带登录态,不支持 PDF;也可以试试 DeepSeek Fetch API', { exact: true }).count()).toBe(1)
expect(await searchSection.getByText('会产生费用,由「计费方式」选定的账号或 API Key 支付。', { exact: true }).count()).toBe(1)
expect(await fetchSection.getByText('免费,可访问内网和本机。不支持需要 JavaScript 或登录的页面,也不支持 PDF。', { exact: true }).count()).toBe(1)
// The Fetch API suggestion is text, not an action.
expect(await fetchSection.getByRole('button').count()).toBe(1)
expect(await save.isDisabled()).toBe(true)
// The Search panel holds only the result count.
// The Search panel holds the result count and the billing menu, automatic by default.
expect(await searchSection.getByRole('textbox').count()).toBe(1)
expect(await panel.locator('#plugin-config-web-search-billing').textContent()).toBe('自动')
expect(await count.inputValue()).toBe('10')
await count.fill('51')
await panel.getByText('请填 1–50 的整数;留空表示使用默认值。', { exact: true }).waitFor()
@@ -198,14 +199,22 @@ describe('web e2e: plugin configuration pages', () => {
await count.fill('10')
expect(await save.isDisabled()).toBe(true)
// The Fetch API takes no settings here, so its section shows no panel.
// The Fetch API shows only its billing menu, and the page saves the billing with the selection.
await choose(fetchMenu, 'DeepSeek Fetch API')
expect(await fetchSection.getByText('由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', { exact: true }).count()).toBe(1)
expect(await fetchSection.getByText('无法访问内网和本机。会产生费用,由「计费方式」选定的账号或 API Key 支付。', { exact: true }).count()).toBe(1)
expect(await fetchSection.getByRole('textbox').count()).toBe(0)
expect(await fetchSection.getByRole('button').count()).toBe(1)
expect(await fetchSection.getByRole('button').count()).toBe(2)
const fetchBilling = panel.locator('#plugin-config-web-fetch-billing')
await choose(fetchBilling, 'DeepSeek API Key')
await fetchSection.getByText('使用「模型」页配置的 API Key,需要先配置。', { exact: true }).waitFor()
await save.click()
await expect.poll(() => save.isDisabled()).toBe(true)
await expect.poll(settingsDocument).toContain('fetchProvider: deepseek-fetch-api')
await expect.poll(settingsDocument).toContain('fetchBilling: api-key')
await choose(fetchBilling, '自动')
await save.click()
await expect.poll(() => save.isDisabled()).toBe(true)
await expect.poll(settingsDocument).not.toContain('fetchBilling:')
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -16,8 +16,8 @@ export const FIRST = 'SYNTHETIC_REPLY_FIRST'
export const DONE = 'SYNTHETIC_REPLY_DONE'
/** Paced text chunks per continuation. */
export const DELTAS = 120
/** Replay delay per stream chunk, in milliseconds. */
export const PACE_MS = 16
/** Replay delay per stream chunk, in milliseconds; keeps the input-overlap probe inside the stream on loaded CI runners. */
export const PACE_MS = 32
/** Create mixed prose, code, reasoning and tool history without reading user data.
* @returns Current Session JSONL accepted by the shared Web seeder.
+1 -1
View File
@@ -47,7 +47,7 @@
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"zod": "^4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
+10 -4
View File
@@ -961,8 +961,6 @@ export interface Config {
mode: 'launch' | 'attach'
/** CDP HTTP or WebSocket endpoint, required only for attach mode. */
cdpEndpoint?: string
/** Optional Stagehand extension id for an existing browser. */
extensionId?: string
/** Installed Chrome/Chromium executable used in launch mode. */
executablePath?: string
/** Hide an owned browser's window. */
@@ -4740,12 +4738,12 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts)
- `source`: [`packages/web/web-deepseek-api/src/index.ts:64`](../packages/web/web-deepseek-api/src/index.ts)
```ts config-catalog
/**
* Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or
* `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek
* `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, its `*Billing` chooses its DeepSeek
* credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load
* without error and have no effect.
*/
@@ -4760,6 +4758,14 @@ export interface Config {
searchRequestsPerSecond: Volatile<number>
/** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */
sendOriginalQuestion: Volatile<boolean>
/**
* Credential Search sends without {@link searchBaseURL} and {@link searchApiKeyEnv}. `auto` uses the conversation's
* DeepSeek account or API key while it chats on a DeepSeek model route, and otherwise the API key, then the signed-in
* account; `account` uses only the signed-in account; `api-key` uses only the API key. Defaults to `auto`.
*/
searchBilling: Volatile<'auto' | 'account' | 'api-key'>
/** Credential Fetch sends without {@link fetchBaseURL} and {@link fetchApiKeyEnv}, chosen as {@link searchBilling} is. */
fetchBilling: Volatile<'auto' | 'account' | 'api-key'>
/**
* Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the
* official base applies.
+10 -4
View File
@@ -963,8 +963,6 @@ export interface Config {
mode: 'launch' | 'attach'
/** CDP HTTP or WebSocket endpoint, required only for attach mode. */
cdpEndpoint?: string
/** Optional Stagehand extension id for an existing browser. */
extensionId?: string
/** Installed Chrome/Chromium executable used in launch mode. */
executablePath?: string
/** Hide an owned browser's window. */
@@ -4742,12 +4740,12 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts)
- `source`: [`packages/web/web-deepseek-api/src/index.ts:64`](../packages/web/web-deepseek-api/src/index.ts)
```ts config-catalog
/**
* Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or
* `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek
* `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, its `*Billing` chooses its DeepSeek
* credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load
* without error and have no effect.
*/
@@ -4762,6 +4760,14 @@ export interface Config {
searchRequestsPerSecond: Volatile<number>
/** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */
sendOriginalQuestion: Volatile<boolean>
/**
* Credential Search sends without {@link searchBaseURL} and {@link searchApiKeyEnv}. `auto` uses the conversation's
* DeepSeek account or API key while it chats on a DeepSeek model route, and otherwise the API key, then the signed-in
* account; `account` uses only the signed-in account; `api-key` uses only the API key. Defaults to `auto`.
*/
searchBilling: Volatile<'auto' | 'account' | 'api-key'>
/** Credential Fetch sends without {@link fetchBaseURL} and {@link fetchApiKeyEnv}, chosen as {@link searchBilling} is. */
fetchBilling: Volatile<'auto' | 'account' | 'api-key'>
/**
* Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the
* official base applies.
@@ -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.2-rc.1/web-search-billing/guide.md
/:
en: d5f047c8126e2039
zh: 921852a96d5fdaf2
/deepseek-web-search-and-fetch-choose-their-credential-by-billing:
en: 5b4ad8a6553f2017
zh: 33d0dbee4bbadad8
/deepseek-web-search-and-fetch-choose-their-credential-by-billing/change:
en: c479258a05c03e30
zh: 9479ed43eebd03c2
/deepseek-web-search-and-fetch-choose-their-credential-by-billing/migration:
en: f02650a1b0730ecf
zh: e6ddb011af754005
@@ -0,0 +1,41 @@
---
kind: upgrade-guide
description: "DeepSeek Search and Fetch add per-capability billing and replace provider authentication fields with ordered credential sources."
---
# DeepSeek web search and fetch choose their credential by billing
English | [中文](guide.zh.md)
## Change
The DeepSeek Search and Fetch APIs (`web-deepseek-api`) previously used the signed-in account only in `deepseek-account` conversations; other conversations required an API key. New `searchBilling` and `fetchBilling` settings accept `auto` (default), `account`, or `api-key`. With `auto`, DeepSeek model routes behave as before; other conversations and operations outside a Session now use the signed-in account when no key exists. `account` uses only the account; `api-key` only the key. Non-empty `*BaseURL` or `*ApiKeyEnv` settings still take precedence. Other providers, including local HTTP fetch, are unaffected.
In TypeScript, `DeepSeekSearchApiOptions` and `DeepSeekFetchApiOptions` lose their top-level `baseURL`. Their `auth` becomes `{ sources, missingCredentialMessage? }`. Each account or API-key source carries its own address; list order replaces the implicit account-first order. Direct provider constructors must migrate.
With an `AbortSignal`, credential-read errors now retain the original `Error` as `cause` and its name in the diagnostic without an extra `Error:` wrapper. A resolver throwing a DOMException named `AbortError` now yields `WEB_ABORTED`, not `WEB_PROVIDER_ERROR`, even when that signal is not aborted. The no-signal path already behaves this way.
## Migration
1. To disable the new account fallback, set both capabilities to `api-key` on the `web-deepseek-api` row, or choose **DeepSeek API key** under **Plugins > Built-in plugin settings > Web search and fetch** when each DeepSeek API is selected:
```yaml
- name: '@deepseek-ai/dsh-web-deepseek-api'
config:
searchBilling: api-key
fetchBilling: api-key
```
This also disables account billing in account conversations; it does not fully restore the old route-dependent behavior.
2. Migrate direct `DeepSeekSearchApiProvider` and `DeepSeekFetchApiProvider` constructors. The table describes TypeScript options, not YAML settings:
| Old option | Replacement |
|---|---|
| `baseURL: base`, `auth.apiKey: key` | `auth.sources: [{ kind: 'api-key', baseURL: base, key }]` |
| `auth.resolveApiKey`, `auth.apiKeyEnv` | An API-key source with `key: { ref, configured, resolve }`. Keep the reference (`DEEPSEEK_API_KEY` when omitted); `configured()` checks presence without reading the value, and `resolve()` reads it. |
| `auth.account` | `{ kind: 'account', ...account }`; preserve its address and callbacks. Place it before the Key source to retain account-first behavior. |
| `auth.missingKeyMessage` | `auth.missingCredentialMessage` |
| `auth.invalidRoute` | `{ kind: 'misconfigured', message }` at the position of the invalid source; it fails only when reached. |
3. Update error handling to recognize resolver `AbortError` as cancellation. Do not depend on the extra error wrapper or its diagnostic prefix.
4. Confirm migrated constructors type-check. With the DeepSeek APIs selected and `api-key` billing, missing keys withhold their tools; configured keys allow requests. Local HTTP fetch remains unaffected.
@@ -0,0 +1,41 @@
---
kind: upgrade-guide
description: "DeepSeek Search 与 Fetch 新增按能力选择的计费方式,并将提供方认证字段替换为有序凭据来源。"
---
# DeepSeek 网页搜索与获取按计费方式选择凭据
[English](guide.md) | 中文
## 变更
DeepSeek Search 与 Fetch API(`web-deepseek-api`)过去只在 `deepseek-account` 会话中使用已登录账号;其他会话需要 API 密钥。新增的 `searchBilling` 与 `fetchBilling` 设置接受 `auto`(默认)、`account` 或 `api-key`。在 `auto` 下,DeepSeek 模型路由的行为不变;其他会话与会话之外的操作在没有密钥时改用已登录账号。`account` 只使用账号,`api-key` 只使用密钥。非空的 `*BaseURL` 或 `*ApiKeyEnv` 设置仍然优先。其他提供方(包括本地 HTTP 抓取)不受影响。
在 TypeScript 中,`DeepSeekSearchApiOptions` 与 `DeepSeekFetchApiOptions` 移除了顶层 `baseURL`。其 `auth` 改为 `{ sources, missingCredentialMessage? }`。每个账号或 API 密钥来源携带自己的地址;列表顺序替代隐式的账号优先顺序。直接调用提供方构造函数的代码需要迁移。
传入 `AbortSignal` 时,凭据读取错误现在保留原始 `Error` 作为 `cause`,诊断也保留其名称,不再额外包装一层 `Error:`。解析函数抛出名为 `AbortError` 的 DOMException 时,即使该 signal 未取消,现在也返回 `WEB_ABORTED`,而非 `WEB_PROVIDER_ERROR`。未传 signal 的路径原本就采用此行为。
## 迁移
1. 要禁用新增的账号回退,在 `web-deepseek-api` 行把两个能力都设为 `api-key`;也可以在**插件 > 内置插件配置 > 网页搜索与获取**中,选中相应的 DeepSeek API 后选择 **DeepSeek API Key**:
```yaml
- name: '@deepseek-ai/dsh-web-deepseek-api'
config:
searchBilling: api-key
fetchBilling: api-key
```
这也会禁用账号会话中的账号计费,并不能完全恢复原来按路由选择的行为。
2. 迁移直接调用 `DeepSeekSearchApiProvider` 与 `DeepSeekFetchApiProvider` 构造函数的代码。下表描述 TypeScript 选项,而非 YAML 设置:
| 原选项 | 替换方式 |
|---|---|
| `baseURL: base`、`auth.apiKey: key` | `auth.sources: [{ kind: 'api-key', baseURL: base, key }]` |
| `auth.resolveApiKey`、`auth.apiKeyEnv` | API 密钥来源的 `key: { ref, configured, resolve }`。保留原引用(省略时为 `DEEPSEEK_API_KEY`);`configured()` 只检查是否存在,不读取值,`resolve()` 读取值。 |
| `auth.account` | `{ kind: 'account', ...account }`;保留其地址和回调。放在 Key 来源之前可保留账号优先行为。 |
| `auth.missingKeyMessage` | `auth.missingCredentialMessage` |
| `auth.invalidRoute` | 在无效来源的位置放置 `{ kind: 'misconfigured', message }`;只有轮到该项才失败。 |
3. 更新错误处理,将解析函数的 `AbortError` 识别为取消。不要依赖额外的错误包装层或其诊断前缀。
4. 确认迁移后的构造代码通过类型检查。选中 DeepSeek API 并使用 `api-key` 计费时,缺少密钥会隐藏相应工具;配置密钥后可以发请求。本地 HTTP 抓取不受影响。
+1 -1
View File
@@ -263,7 +263,7 @@
"@yarnpkg/cli-dist": "4.18.0",
"eslint-plugin-sonarjs": "^4.2.0",
"execa": "^10.0.1",
"zod": "^4.4.3",
"zod": "4.6.6",
"extract-zip": "^2.0.1",
"fast-check": "^4.9.0",
"fflate": "^0.8.2",
+1 -1
View File
@@ -50,7 +50,7 @@
],
"license": "MIT",
"dependencies": {
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+1 -1
View File
@@ -83,6 +83,6 @@
"@deepseek-ai/dsh-util-crypto": "workspace:*",
"@types/ws": "^8.18.1",
"@deepseek-ai/cordis": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
}
}
+1 -1
View File
@@ -73,7 +73,7 @@
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
+1 -1
View File
@@ -72,7 +72,7 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
+1 -1
View File
@@ -92,7 +92,7 @@
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-user-questions": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-command-feedback": "workspace:*",
"@deepseek-ai/dsh-api-terminal-controller": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
+1 -1
View File
@@ -80,7 +80,7 @@
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"mime-types": "^3.0.2",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -51,7 +51,7 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -62,7 +62,7 @@
"@deepseek-ai/schemastery": "workspace:~",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/headless": "^6.0.0",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/dsh-subprocess": "workspace:*",
@@ -78,7 +78,7 @@
"@deepseek-ai/dsh-deque": "workspace:*",
"@deepseek-ai/dsh-native-command": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+1 -1
View File
@@ -56,7 +56,7 @@
"@deepseek-ai/dsh-deque": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
+1 -1
View File
@@ -75,7 +75,7 @@
"execa": "^10.0.1",
"semver": "^7.8.5",
"yaml": "^2.9.0",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-plugin-detail": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*",
+1 -1
View File
@@ -68,7 +68,7 @@
"hosted-git-info": "^9.0.3",
"minisearch": "^7.2.0",
"semver": "^7.8.5",
"zod": "^4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+1 -1
View File
@@ -39,7 +39,7 @@
"dependencies": {
"@deepseek-ai/dsh-credentials": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"files": [
"locale/*.json",
+1 -1
View File
@@ -68,7 +68,7 @@
"src"
],
"dependencies": {
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*"
},
@@ -85,6 +85,6 @@
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
}
}
+1 -1
View File
@@ -99,7 +99,7 @@
"@deepseek-ai/dsh-message-quotes": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
},
"files": [
@@ -93,7 +93,7 @@
"dependencies": {
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
}
}
@@ -3,26 +3,26 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-web-search/README.md
/:
en: 88b8ad7d3580e897
zh: 36380f07046212c5
en: c998138f550b2c5c
zh: 7b7998c9801e1bdd
/deepseek-ai-dsh-client-ui-settings-web-search:
en: 968c0f6ba2415331
zh: aaf9e2aa22eafc98
/deepseek-ai-dsh-client-ui-settings-web-search/summary:
en: 93af84c75a57345b
zh: 67836fe89e423a52
en: 23c442a512078061
zh: f10ddb1d6afe7a82
/deepseek-ai-dsh-client-ui-settings-web-search/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-settings-web-search/use-this-package:
en: bf26f223253bb9b8
zh: b687ab66557c1f95
en: b00497ce4cf39d47
zh: 9663997b7a4cc959
/deepseek-ai-dsh-client-ui-settings-web-search/understand-the-implementation:
en: 078184ee0b31389a
zh: 8d74eb3b687a2d6f
en: 836d3f3032033148
zh: f5b5c1ee14450ab5
/deepseek-ai-dsh-client-ui-settings-web-search/further-exploration:
en: 782f6f99769f5645
zh: 310a9544ae051c9d
en: 892dc41e9012f3c4
zh: dc7cf90868141c0d
/deepseek-ai-dsh-client-ui-settings-web-search/model-experience:
en: 6b7c6f82ab6c8435
zh: 4517689c899abcbe
@@ -30,8 +30,8 @@
en: ca75c51c89c2c9b0
zh: 6e1c9a8fdde89577
/deepseek-ai-dsh-client-ui-settings-web-search/known-limitations-and-deferred-work:
en: 0024baf967a2913b
zh: c6bd2f9eb52d83e3
en: 5c6dad603abec1c3
zh: a594f9def4ef8215
/deepseek-ai-dsh-client-ui-settings-web-search/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -1,5 +1,5 @@
---
description: "The web search and fetch settings page on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, and legacy Messages search settings."
description: "The web search and fetch settings page on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, DeepSeek Search and Fetch API billing, and legacy Messages search settings."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Open **Plugins** in the sidebar and select **Web search and fetch** in the Official group to choose the providers web search and web fetch use, set the Search API result count, or configure legacy Messages search. The DeepSeek Search and Fetch APIs take no credential settings here: they use the conversation's DeepSeek account or a DeepSeek API key. The page writes only on save; a legacy key goes through the credentials domain. The page exists while the Host serves the `web` namespace; each official provider's controls appear only while the Host serves that provider's namespace.
Open **Plugins** in the sidebar and select **Web search and fetch** in the Official group to choose the providers web search and web fetch use, set the Search API result count, or configure legacy Messages search. The DeepSeek Search and Fetch APIs each take a **Billing** choice instead of a key: automatic, the signed-in DeepSeek account, or the DeepSeek API key. The page writes only on save; a legacy key goes through the credentials domain. The page exists while the Host serves the `web` namespace; each official provider's controls appear only while the Host serves that provider's namespace.
## Table of Contents
@@ -27,7 +27,7 @@ Open **Plugins** in the sidebar and select **Web search and fetch** in the Offic
The **Web search and fetch** card in the Official group opens the page. It has a **Web search provider** section and a **Web fetch provider** section, each with a provider menu directly under its title. A menu lists every provider the Host registers, including providers that plugins register, so a newly installed provider needs no change to this page; [Add another provider](../../web/web/README.md#adding-a-provider) shows how to mount one in a profile. The official ones come first while registered: the Search menu lists **DeepSeek Search API** (`deepseek-search-api`) followed by **DeepSeek (legacy)** (`deepseek-official`), and the Fetch menu lists **Direct fetch** (`http`) and then **DeepSeek Fetch API** (`deepseek-fetch-api`); every other provider follows in registration order and shows its registered title or id. The shipped composition selects DeepSeek Search API for Search and Direct fetch for Fetch, and a selection stored in settings or composed by the deployment still applies; opening the page writes nothing. Each menu has no label of its own; its accessible name is its section title followed by the selected provider, such as **Web search provider DeepSeek Search API**. A selected id the Host does not register appears as an extra **(not loaded)** entry and stays selected. While the deployment composes no provider for a capability, its menu starts with **Automatic**, which clears the stored selection so the Host chooses again. Selecting the deployment's composed provider clears the stored selection instead of writing a copy. When the provider list cannot be read, each section says its menu may be incomplete and offers **Retry**; the menus keep the last list read, or only the selected ids before any list arrives. Under each menu, the section shows the `description` the selected provider registered, in the active language, as plain text; a provider that registers none, or a selected id the Host does not register, shows no description. The official providers' descriptions state each one's credentials, charges, model calls, reachable addresses, and content limits, and Direct fetch's ends by suggesting DeepSeek Fetch API as plain text. A plugin provider's section adds, after its description, that it is configured in that plugin's own settings.
Under **Web search provider**, DeepSeek Search API adds a link to the DeepSeek search and fetch guide after its description, and a gray panel below the menu holds **Results per search** (an integer from 1 through 50, default 10; invalid counts block saving). The DeepSeek Fetch API section shows its provider menu and description. Both APIs follow the DeepSeek credential of the conversation, and their own endpoint fields are YAML-only ([web-deepseek-api authentication](../../web/web-deepseek-api/README.md#authentication)). A provider with no settings on this page, such as DeepSeek Fetch API, Direct fetch, or a plugin provider, shows no panel. Every control sits directly in its section; nothing is collapsed.
Under **Web search provider**, DeepSeek Search API adds a link to the DeepSeek search and fetch guide after its description, and a gray panel below the menu holds **Results per search** (an integer from 1 through 50, default 10; invalid counts block saving). The DeepSeek Fetch API section shows its provider menu and description. Each API's panel holds a **Billing** menu, writing `web-deepseek-api.searchBilling` or `fetchBilling`, with the selected billing's explanation below it: **Automatic** (`auto`, the default) uses the conversation's DeepSeek account or API key when it chats on DeepSeek, and otherwise the DeepSeek API key, then the signed-in DeepSeek account; **Signed-in DeepSeek account** (`account`) and **DeepSeek API key** (`api-key`) use only that credential. Selecting the deployment's composed billing, `auto` when it composes none, clears the stored billing. The page does not show whether the selected credential is available. The APIs' own endpoint fields are YAML-only and take precedence over billing ([web-deepseek-api authentication](../../web/web-deepseek-api/README.md#authentication)); while a capability has a non-empty `*BaseURL` or `*ApiKeyEnv`, its **Billing** label carries an **Overridden by** tag naming the set fields, and its menu is disabled. The page reads these fields and never writes or clears them. A provider with no settings on this page, such as Direct fetch or a plugin provider, shows no panel. Every control sits directly in its section; nothing is collapsed.
**DeepSeek (legacy)** shows its API key, independent Messages Base URL, and **Maximum searches per request** (`maxUses`, a positive integer, default 5) in the same panel layout. The key writes the `web-search-deepseek.apiKeyEnv` reference (`DEEPSEEK_API_KEY` by default); endpoint and limit writes stay in `web-search-deepseek`, separate from API settings. Switching away drops an unsaved key but preserves endpoint and limit settings.
@@ -43,7 +43,7 @@ A staged edit to a hidden provider's field is kept and saved. One save writes th
<details>
<summary>Implementation internals — click to expand</summary>
The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web`, `web-search-deepseek`, and `web-deepseek-api` namespaces through `ctx.configForms.get` and keeps one shared `SettingsFormModel` of `ui-primitives` per namespace in `WebSearchCardController`. The menus stage `searchProvider` and `fetchProvider` in `web`; a target equal to the composed value stages a reset instead, and so does the **Automatic** entry, which the menu offers while the composed `base` has no value for that field. The provider list comes from `remote.web.listProviders`, which keeps registration order; the controller moves the registered official providers to the front in its fixed order and keeps the rest in Host order. The list is read at construction, on each `web/providers-changed` event, on `connection/reset`, and when the `web` namespace becomes served; only the latest read publishes, a failed read keeps the previous list and sets the snapshot's `providersFailed` until a later read succeeds, and before the first successful answer a menu holds only the selected id. The face's `retryProviders` re-reads through the same sequence and disposal checks. The `web-deepseek-api` form stages only `count`. The selection form's secret control is `legacyApiKey`, which addresses `web-search-deepseek.apiKeyEnv`. Its draft records the reference the control addressed when it was typed; on every republish the draft is dropped when the page no longer shows the control or the reference changed. A save fixes the draft reference when the selection form plans its writes, so a key write planned before its provider disappears still writes under that reference. A write goes to `remote.credentials.set`, and success is read back from `remote.credentials.describe`, which one read asks for the current reference and the written one. The controller combines synchronous scope publications and reference switches into one credential read in the next microtask; scope refreshes also resynchronize credentials after a connection reset. A reference change clears the credential state immediately. Host `credentials/reference-updated` events for the watched reference and writes trigger independent reads, including while an earlier read is pending. Only the latest read updates the card, and only while its reference is unchanged; a save uses its own readback result. Disposal stops queued reads and suppresses late answers. `WebSearchCard` renders the provider menus with one local menu button over the `Menu` primitive and the legacy fields with `SettingsSecretField` and `SettingsValueField`. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.configForms.whileServed([web])`.
The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web`, `web-search-deepseek`, and `web-deepseek-api` namespaces through `ctx.configForms.get` and keeps one shared `SettingsFormModel` of `ui-primitives` per namespace in `WebSearchCardController`. The menus stage `searchProvider` and `fetchProvider` in `web`; a target equal to the composed value stages a reset instead, and so does the **Automatic** entry, which the menu offers while the composed `base` has no value for that field. The provider list comes from `remote.web.listProviders`, which keeps registration order; the controller moves the registered official providers to the front in its fixed order and keeps the rest in Host order. The list is read at construction, on each `web/providers-changed` event, on `connection/reset`, and when the `web` namespace becomes served; only the latest read publishes, a failed read keeps the previous list and sets the snapshot's `providersFailed` until a later read succeeds, and before the first successful answer a menu holds only the selected id. The face's `retryProviders` re-reads through the same sequence and disposal checks. The `web-deepseek-api` form stages `count`, `searchBilling`, and `fetchBilling`; the snapshot carries each billing only while its API is selected, reads an unset billing as `auto`, lists the capability's non-empty `*BaseURL` and `*ApiKeyEnv` from the served section as `advancedOverride` without staging them, and `selectBilling` stages a reset when the target equals the composed `base` billing, or `auto` without one. The selection form's secret control is `legacyApiKey`, which addresses `web-search-deepseek.apiKeyEnv`. Its draft records the reference the control addressed when it was typed; on every republish the draft is dropped when the page no longer shows the control or the reference changed. A save fixes the draft reference when the selection form plans its writes, so a key write planned before its provider disappears still writes under that reference. A write goes to `remote.credentials.set`, and success is read back from `remote.credentials.describe`, which one read asks for the current reference and the written one. The controller combines synchronous scope publications and reference switches into one credential read in the next microtask; scope refreshes also resynchronize credentials after a connection reset. A reference change clears the credential state immediately. Host `credentials/reference-updated` events for the watched reference and writes trigger independent reads, including while an earlier read is pending. Only the latest read updates the card, and only while its reference is unchanged; a save uses its own readback result. Disposal stops queued reads and suppresses late answers. `WebSearchCard` renders the provider menus with one local menu button over the `Menu` primitive the billing menus with the same local menu button, and the legacy fields with `SettingsSecretField` and `SettingsValueField`. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.configForms.whileServed([web])`.
</details>
@@ -56,7 +56,7 @@ The Host half is an empty `apply`, present only so the package holds a Loader ro
- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides.
- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders.
- [credentials](../../credentials/README.md) — the credential-reference seam the legacy key writes through.
- [web-deepseek-api](../../web/web-deepseek-api/README.md) — the Search and Fetch API providers whose result count the Search section edits.
- [web-deepseek-api](../../web/web-deepseek-api/README.md) — the Search and Fetch API providers whose result count and billing the page edits.
- [web](../../web/web/README.md) — the provider selection and provider list the menus use.
-----
@@ -76,6 +76,8 @@ None; this package neither assembles nor sends a provider request.
- **Automatic selection reads only the deployment composition** — a menu offers it when the composition layer sets no provider for that capability. While automatic selection applies, the Host's `DSH_WEB_SEARCH_PROVIDER` or `DSH_WEB_FETCH_PROVIDER` environment variable picks the provider instead, and the page does not show that choice. Without either, automatic selection with more than one usable registered provider makes each operation fail with `WEB_PROVIDER_AMBIGUOUS`.
- **Billing availability is not shown** — the page does not report that the selected billing has no signed-in account or no configured key; reading sign-in state needs the account remote, and the key reference of the DeepSeek API-key model provider is not visible to this page. An unavailable billing withholds the web tool from the model.
<a id="dev-note"></a>
### Dev Note
@@ -1,5 +1,5 @@
---
description: "dsh Web 客户端插件页上的网页搜索与获取设置页:Search 与 Fetch 提供方下拉框、DeepSeek Search API 的结果数,以及旧版 Messages 搜索的设置。"
description: "dsh Web 客户端插件页上的网页搜索与获取设置页:Search 与 Fetch 提供方下拉框、DeepSeek Search API 的结果数、DeepSeek Search 与 Fetch API 的计费方式,以及旧版 Messages 搜索的设置。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
在侧栏打开**插件**,在官方分组里选择**网页搜索与获取**,即可选择网页搜索和网页获取使用的提供方、设置 Search API 的结果数,或配置旧版 Messages 搜索。DeepSeek Search 与 Fetch API 在这里没有凭据设置:它们使用会话的 DeepSeek 账号或 DeepSeek API Key。页面只在保存时写入;旧版搜索的密钥经凭据域写入。页面在 Host 服务 `web` 命名空间期间存在;各官方提供方的控件只在 Host 服务该提供方命名空间时显示。
在侧栏打开**插件**,在官方分组里选择**网页搜索与获取**,即可选择网页搜索和网页获取使用的提供方、设置 Search API 的结果数,或配置旧版 Messages 搜索。DeepSeek Search 与 Fetch API 不填写密钥,而是各选一种**计费方式**:自动、已登录的 DeepSeek 账号或 DeepSeek API Key。页面只在保存时写入;旧版搜索的密钥经凭据域写入。页面在 Host 服务 `web` 命名空间期间存在;各官方提供方的控件只在 Host 服务该提供方命名空间时显示。
## 目录
@@ -27,7 +27,7 @@ kind: "package-reference"
官方分组里的**网页搜索与获取**卡片打开这一页。页面分为**网页搜索提供方**与**网页获取提供方**两节,每节标题下方紧接一个提供方下拉框。下拉框列出 Host 注册的全部提供方,包括插件注册的提供方,因此新安装的提供方无需修改本页;在 profile 中挂载提供方的方法见[添加其他提供方](../../web/web/README.zh.md#adding-a-provider)。已注册的官方提供方排在最前:搜索下拉框依次为 **DeepSeek Search API**(`deepseek-search-api`)与 **DeepSeek(旧版)**(`deepseek-official`),获取下拉框依次为**本机直接获取**(`http`)和 **DeepSeek Fetch API**(`deepseek-fetch-api`);其他提供方按注册顺序排在其后,显示其注册的标题或 id。随附组合默认搜索选择 DeepSeek Search API、获取选择本机直接获取,设置中已存的选择或部署组合的选择仍然生效;打开页面不会写入任何内容。下拉框没有单独的标签,其无障碍名称由所在节的标题和当前选中的提供方组成,例如**网页搜索提供方 DeepSeek Search API**。Host 未注册的已选 id 额外显示为一项**(未加载)**,并保持选中。部署没有为某项能力组合提供方时,其下拉框首项为**自动选择**,选中它会清除已存的选择,由 Host 重新选择。选择部署组合的提供方时清除已存的选择,而不写入一份副本。提供方列表读取失败时,每节提示其下拉框可能不完整并提供**重试**;下拉框保留上一次读到的列表,尚未读到任何列表时只包含已选 id。每节在下拉框下方以纯文本显示所选提供方注册的 `description`(按当前语言);未注册说明的提供方或 Host 未注册的已选 id 不显示说明。官方提供方的说明写明各自的凭据、计费、模型调用、可访问的地址和内容限制,本机直接获取的说明末尾以纯文本建议改用 DeepSeek Fetch API。插件提供方所在的节在其说明之后提示到该插件自己的设置中配置。
**网页搜索提供方**一节中,DeepSeek Search API 在说明之后显示 DeepSeek 网页搜索与获取指南链接,下拉框下方的灰色面板中是**每次搜索结果数**(1–50 的整数,默认 10;无效值阻止保存)。DeepSeek Fetch API 所在节显示提供方下拉框及说明。两个 API 都跟随会话的 DeepSeek 凭据,单独配置的端点字段只能在 YAML 中设置([web-deepseek-api 鉴权](../../web/web-deepseek-api/README.zh.md#authentication))。在本页没有设置项的提供方(例如 DeepSeek Fetch API、本机直接获取或插件提供方)不显示面板。所有控件直接位于所在节中,没有折叠区。
**网页搜索提供方**一节中,DeepSeek Search API 在说明之后显示 DeepSeek 网页搜索与获取指南链接,下拉框下方的灰色面板中是**每次搜索结果数**(1–50 的整数,默认 10;无效值阻止保存)。DeepSeek Fetch API 所在节显示提供方下拉框及说明。每个 API 的面板中有一个**计费方式**下拉框,写入 `web-deepseek-api.searchBilling` 或 `fetchBilling`,下方显示所选计费方式的说明:**自动**(`auto`,默认)在会话使用 DeepSeek 模型时沿用其账号或 API Key,否则依次使用 DeepSeek API Key、已登录的 DeepSeek 账号;**已登录的 DeepSeek 账号**(`account`)和 **DeepSeek API Key**(`api-key`)只使用该凭据。选择部署组合的计费方式(未组合时为 `auto`)会清除已存的计费方式。页面不显示所选凭据当前是否可用。两个 API 单独配置的端点字段只能在 YAML 中设置,并优先于计费方式([web-deepseek-api 鉴权](../../web/web-deepseek-api/README.zh.md#authentication));某一能力的 `*BaseURL` 或 `*ApiKeyEnv` 非空时,其**计费方式**标题旁显示**已被…覆盖**标签并列出已设置的字段,下拉框不可用。页面只读取这些字段,从不写入或清除。在本页没有设置项的提供方(例如本机直接获取或插件提供方)不显示面板。所有控件直接位于所在节中,没有折叠区。
**DeepSeek(旧版)**在同样的面板布局中显示 API Key、独立的 Messages Base URL 与**单次请求最多搜索次数**(`maxUses`,正整数,默认 5)。密钥写入 `web-search-deepseek.apiKeyEnv` 指向的引用(默认 `DEEPSEEK_API_KEY`);端点和次数上限写入 `web-search-deepseek`,与 API 设置分开。切换其他提供方会丢弃未保存的密钥,但保留端点和次数设置。
@@ -43,7 +43,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.configForms.get` 绑定 `web`、`web-search-deepseek` 和 `web-deepseek-api` 命名空间,在 `WebSearchCardController` 里为每个命名空间维护一个 `ui-primitives` 共享的 `SettingsFormModel`。下拉框在 `web` 中暂存 `searchProvider` 和 `fetchProvider`;目标值等于组合值时改为暂存重置;组合的 `base` 对该字段没有值时,下拉框提供**自动选择**项,选中它同样暂存重置。提供方列表来自 `remote.web.listProviders`,该列表保持注册顺序;控制器把已注册的官方提供方按固定顺序移到最前,其余保持 Host 顺序。列表在构造时、每次 `web/providers-changed` 事件、`connection/reset` 以及 `web` 命名空间开始被服务时读取;只有最新一次读取会发布,读取失败时保留上一份列表,并在快照中设置 `providersFailed`,直到之后某次读取成功;首次成功应答前下拉框只包含已选 id。面上的 `retryProviders` 沿用同一套序号与释放检查重新读取。`web-deepseek-api` 表单只暂存 `count`。选择表单的密文控件为 `legacyApiKey`,使用 `web-search-deepseek.apiKeyEnv` 引用。草稿记录输入时控件所使用的引用;每次重新发布时,页面不再显示该控件或引用已变化时丢弃草稿。保存在选择表单规划写入时固定草稿的引用,因此在提供方消失前已规划的密钥写入仍写到该引用。写入走 `remote.credentials.set`,是否成功由 `remote.credentials.describe` 回读判定,一次读取同时询问当前引用和刚写入的引用。控制器把同步发布的 scope 更新和引用切换合并成下一个微任务中的一次凭据读取;连接重置后的 scope 刷新也会重新同步凭据。引用变化会立即清除旧凭据状态。Host 对所监视引用发出 `credentials/reference-updated` 事件以及写入时,都会独立触发读取,即使此前的读取仍未完成。只有最新一次读取会更新卡片,且只在其引用未变时更新;保存使用自身的回读结果。释放控制器后不再执行排队的读取,也不再发布迟到的响应。`WebSearchCard` 用基于 `Menu` 基础组件的同一个本地下拉按钮渲染提供方下拉框,用 `SettingsSecretField` 和 `SettingsValueField` 渲染旧版搜索的字段。页面通过 `ctx.configForms.whileServed([web])` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。
宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.configForms.get` 绑定 `web`、`web-search-deepseek` 和 `web-deepseek-api` 命名空间,在 `WebSearchCardController` 里为每个命名空间维护一个 `ui-primitives` 共享的 `SettingsFormModel`。下拉框在 `web` 中暂存 `searchProvider` 和 `fetchProvider`;目标值等于组合值时改为暂存重置;组合的 `base` 对该字段没有值时,下拉框提供**自动选择**项,选中它同样暂存重置。提供方列表来自 `remote.web.listProviders`,该列表保持注册顺序;控制器把已注册的官方提供方按固定顺序移到最前,其余保持 Host 顺序。列表在构造时、每次 `web/providers-changed` 事件、`connection/reset` 以及 `web` 命名空间开始被服务时读取;只有最新一次读取会发布,读取失败时保留上一份列表,并在快照中设置 `providersFailed`,直到之后某次读取成功;首次成功应答前下拉框只包含已选 id。面上的 `retryProviders` 沿用同一套序号与释放检查重新读取。`web-deepseek-api` 表单暂存 `count`、`searchBilling` 和 `fetchBilling`;快照只在对应 API 被选中时携带其计费方式,未设置的计费方式读作 `auto`,并把所服务配置段中该能力非空的 `*BaseURL` 与 `*ApiKeyEnv` 列为 `advancedOverride`(不暂存这些字段);目标等于组合 `base` 的计费方式(没有时为 `auto`)时,`selectBilling` 暂存重置。选择表单的密文控件为 `legacyApiKey`,使用 `web-search-deepseek.apiKeyEnv` 引用。草稿记录输入时控件所使用的引用;每次重新发布时,页面不再显示该控件或引用已变化时丢弃草稿。保存在选择表单规划写入时固定草稿的引用,因此在提供方消失前已规划的密钥写入仍写到该引用。写入走 `remote.credentials.set`,是否成功由 `remote.credentials.describe` 回读判定,一次读取同时询问当前引用和刚写入的引用。控制器把同步发布的 scope 更新和引用切换合并成下一个微任务中的一次凭据读取;连接重置后的 scope 刷新也会重新同步凭据。引用变化会立即清除旧凭据状态。Host 对所监视引用发出 `credentials/reference-updated` 事件以及写入时,都会独立触发读取,即使此前的读取仍未完成。只有最新一次读取会更新卡片,且只在其引用未变时更新;保存使用自身的回读结果。释放控制器后不再执行排队的读取,也不再发布迟到的响应。`WebSearchCard` 用基于 `Menu` 基础组件的同一个本地下拉按钮渲染提供方下拉框,用同一个本地下拉按钮渲染计费方式下拉框,用 `SettingsSecretField` 和 `SettingsValueField` 渲染旧版搜索的字段。页面通过 `ctx.configForms.whileServed([web])` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。
</details>
@@ -56,7 +56,7 @@ kind: "package-reference"
- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。
- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。
- [credentials](../../credentials/README.zh.md)——旧版搜索密钥写入所经的凭据引用 seam。
- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——Search 一节编辑其结果数的 Search 与 Fetch API 提供方。
- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——本页编辑其结果数与计费方式的 Search 与 Fetch API 提供方。
- [web](../../web/web/README.zh.md)——下拉框使用的提供方选择与提供方列表。
-----
@@ -76,6 +76,8 @@ kind: "package-reference"
- **自动选择只看部署组合层**——组合层没有为该能力设置提供方时,下拉框才提供自动选择。自动选择生效时,Host 的 `DSH_WEB_SEARCH_PROVIDER` 或 `DSH_WEB_FETCH_PROVIDER` 环境变量会改为由它指定提供方,而页面不会显示这一选择。两者都没有时,如果注册了多个可用提供方,自动选择会让每次操作以 `WEB_PROVIDER_AMBIGUOUS` 失败。
- **不显示计费方式是否可用**——所选计费方式没有已登录账号或没有配置密钥时,页面不会提示;读取登录状态需要账号 remote,而本页看不到 DeepSeek API Key 模型提供方实际使用的密钥引用。不可用的计费方式会让模型看不到对应的网页工具。
<a id="dev-note"></a>
### 开发备注
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Einstellungen für Websuche und Webabruf",
"description": "Wählen Sie die Anbieter für Websuche und Webabruf und konfigurieren Sie Schlüssel, Dienstadresse und Limits der DeepSeek-Anbieter."
"description": "Wählen Sie die Anbieter für Websuche und Webabruf und konfigurieren Sie Abrechnung, Schlüssel, Dienstadresse und Limits der DeepSeek-Anbieter."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Configuración de búsqueda y obtención web",
"description": "Elija los proveedores de búsqueda y obtención web y configure la clave, la dirección del servicio y los límites de los proveedores DeepSeek."
"description": "Elija los proveedores de búsqueda y obtención web y configure la facturación, la clave, la dirección del servicio y los límites de los proveedores DeepSeek."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Paramètres de recherche et de récupération web",
"description": "Choisissez les fournisseurs de recherche et de récupération web, et configurez la clé, l'adresse du service et les limites des fournisseurs DeepSeek."
"description": "Choisissez les fournisseurs de recherche et de récupération web, et configurez la facturation, la clé, l'adresse du service et les limites des fournisseurs DeepSeek."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Web 検索と取得の設定",
"description": "Web 検索と Web 取得のプロバイダーを選択し、DeepSeek プロバイダーのキー、サービスアドレス、上限を設定します。"
"description": "Web 検索と Web 取得のプロバイダーを選択し、DeepSeek プロバイダーの課金方法、キー、サービスアドレス、上限を設定します。"
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Configurações de pesquisa e obtenção na web",
"description": "Escolha os provedores de pesquisa e obtenção na web e configure a chave, o endereço do serviço e os limites dos provedores DeepSeek."
"description": "Escolha os provedores de pesquisa e obtenção na web e configure a cobrança, a chave, o endereço do serviço e os limites dos provedores DeepSeek."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "Настройки веб-поиска и загрузки",
"description": "Выберите поставщиков веб-поиска и загрузки и настройте ключ, адрес службы и ограничения поставщиков DeepSeek."
"description": "Выберите поставщиков веб-поиска и загрузки и настройте оплату, ключ, адрес службы и ограничения поставщиков DeepSeek."
}
}
@@ -1,6 +1,6 @@
{
"meta": {
"title": "网页搜索与获取设置",
"description": "选择网页搜索与网页获取的提供方,并配置 DeepSeek 提供方的密钥、服务地址和上限。"
"description": "选择网页搜索与网页获取的提供方,并配置 DeepSeek 提供方的计费方式、密钥、服务地址和上限。"
}
}
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-settings-web-search",
"description": "Settings page for web search and fetch on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, and legacy Messages search settings",
"description": "Settings page for web search and fetch on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, DeepSeek Search and Fetch API billing, and legacy Messages search settings",
"version": "0.2.2-rc.1",
"publishConfig": {
"access": "public"
@@ -1,9 +1,11 @@
/*
* Web search and fetch page: two provider sections, each a title and a menu
* with its hints. Under Search, the DeepSeek Search API shows its result count,
* and the legacy DeepSeek search its API key, base URL, and searches per
* request, in a gray panel. The sections are told apart by spacing and the
* panel; the only hairlines are the settings fields' dividers inside the panel.
* with its hints. Under Search, the DeepSeek Search API shows its result count
* and billing, and the legacy DeepSeek search its API key, base URL, and
* searches per request, in a gray panel; under Fetch, the DeepSeek Fetch API
* shows its billing in the same panel. The sections are told apart by spacing
* and the panel; the only hairlines are the dividers between the fields inside
* the panel.
*/
.section {
@@ -86,6 +88,39 @@
background: var(--dsw-alias-bg-module-platform);
}
/* A panel field built here rather than by the settings form: the billing menu. */
.panelField {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
}
/* Matches the settings fields' own divider, so the billing menu joins their rhythm. */
.panel > * + .panelField {
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
/* Matches the settings fields' label row: the label, then the override tag at the end. */
.panelHead {
display: flex;
align-items: center;
gap: 8px;
}
.panelHead > .panelLabel {
flex: 1;
min-width: 0;
}
/* Matches the settings fields' label. */
.panelLabel {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
color: var(--dsw-alias-label-primary);
}
.hint {
margin: 0;
font-size: 12px;
@@ -1,21 +1,23 @@
/**
* The web search and fetch settings page: one section per capability, each
* with a provider menu and its explanation. The selected provider's settings
* below the menu sit in one gray panel: the result count for the DeepSeek
* Search API, and the key, endpoint, and search limit for legacy Messages
* search. A provider with no settings here shows no panel.
* below the menu sit in one gray panel: the result count and billing for the
* DeepSeek Search API, billing for the DeepSeek Fetch API, and the key,
* endpoint, and search limit for legacy Messages search. A provider with no
* settings here shows no panel.
*/
import { useRef, useState, type ReactNode } from 'react'
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
import {
Button, IconChevronDownOutlineRegular, Menu, SettingsForm, SettingsSecretField, SettingsValueField,
Button, IconChevronDownOutlineRegular, Menu, SettingsForm, SettingsSecretField, SettingsValueField, Tag,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { formLabels, type WebSearchSettingsLocaleKey } from './locales.ts'
import styles from './WebSearchCard.module.css'
import {
PROVIDER_IDS, type WebProviderOption, type WebProviderSelection, type WebSearchCardFace, type WebSearchCardState,
PROVIDER_IDS, type WebBilling, type WebBillingState, type WebProviderOption, type WebProviderSelection,
type WebSearchCardFace, type WebSearchCardState,
} from './web-search-card-controller.ts'
/** Props the renderer binds for the web search and fetch page. */
@@ -80,6 +82,10 @@ export function WebSearchCard(props: WebSearchCardProps) {
overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('invalidCount')} disabled={disabled}
{...state.count}
onEdit={(text) => { props.edit('count', text) }} onReset={() => { props.resetField('count') }} />
{state.searchBilling !== undefined && (
<BillingField capability="search" t={t} billing={state.searchBilling} disabled={disabled}
onSelect={props.selectBilling} resolveText={props.resolveText} />
)}
</div>
)}
{search.kind === 'legacy' && state.legacyKey !== undefined && (
@@ -113,6 +119,12 @@ export function WebSearchCard(props: WebSearchCardProps) {
section={fetch} disabled={menuDisabled} field="fetchProvider" onSelect={props.selectProvider}
providersFailed={state.providersFailed} onRetry={props.retryProviders}
description={describe(fetch)} hint={fetch.kind === 'plugin' ? t('pluginProviderHint') : undefined} />
{state.fetchBilling !== undefined && (
<div className={styles.panel}>
<BillingField capability="fetch" t={t} billing={state.fetchBilling} disabled={disabled}
onSelect={props.selectBilling} resolveText={props.resolveText} />
</div>
)}
</section>
</SettingsForm>
)
@@ -173,6 +185,59 @@ function Dropdown(props: {
)
}
/** Dictionary names of each billing and its explanation, in menu order. */
const BILLING_COPY = {
'auto': { nameKey: 'billingAuto', hintKey: 'billingAutoHint' },
'account': { nameKey: 'billingAccount', hintKey: 'billingAccountHint' },
'api-key': { nameKey: 'billingApiKey', hintKey: 'billingApiKeyHint' },
} as const satisfies Record<WebBilling, { nameKey: WebSearchSettingsLocaleKey; hintKey: WebSearchSettingsLocaleKey }>
/** Billing entries in menu order. */
const BILLINGS: readonly WebBilling[] = ['auto', 'account', 'api-key']
/** Separator between the field names of the advanced-override tag, by language. */
const FIELD_SEPARATOR = { en: ', ', zh: '、', ja: '、', es: ', ', fr: ', ', de: ', ', ru: ', ', pt: ', ' }
/**
* One capability's billing menu with the selected billing's explanation. While the capability's own endpoint fields
* override billing, a tag beside the label names them and the menu is locked.
* @param props - the capability, locale copy, the billing state, the settings lock, the selection callback, and the
* locale resolver for the field-name separator.
* @returns the labelled billing menu.
*/
function BillingField(props: {
capability: 'search' | 'fetch'
t: T
billing: WebBillingState
/** Locks the menu: read-only settings or a save in flight. */
disabled: boolean
onSelect: WebSearchCardFace['selectBilling']
resolveText: WebSearchCardFace['resolveText']
}) {
const { t, billing, capability } = props
const id = `plugin-config-web-${capability}-billing`
const labelId = `${id}-label`
return (
<div className={styles.panelField}>
<div className={styles.panelHead}>
<span id={labelId} className={styles.panelLabel}>{t('billing')}</span>
{billing.advancedOverride.length > 0 && (
<Tag tone="neutral">
{t('billingAdvancedOverride', { fields: billing.advancedOverride.join(props.resolveText(FIELD_SEPARATOR)) })}
</Tag>
)}
</div>
<Dropdown id={id} labelledBy={labelId} items={BILLINGS.map(mode => ({ id: mode, label: t(BILLING_COPY[mode].nameKey) }))}
selectedId={billing.value} valueLabel={t(BILLING_COPY[billing.value].nameKey)}
disabled={props.disabled || billing.advancedOverride.length > 0}
// The menu offers only BILLINGS entries, so the fallback never applies.
/* v8 ignore next */
onSelect={(mode) => { props.onSelect(capability, BILLINGS.find(entry => entry === mode) ?? billing.value) }} />
<p className={styles.hint}>{t(BILLING_COPY[billing.value].hintKey)}</p>
</div>
)
}
/**
* A notice that a list could not be read, with a retry.
* @param props - the notice copy, the retry label, and the retry callback.
@@ -14,6 +14,8 @@ export type WebSearchSettingsLocaleKey =
| 'count' | 'countHint'
| 'overridden' | 'reset' | 'readOnly' | 'unavailable'
| 'save' | 'saving' | 'saveFailed' | 'invalidNumber' | 'invalidCount'
| 'billing' | 'billingAuto' | 'billingAutoHint' | 'billingAccount' | 'billingAccountHint' | 'billingApiKey' | 'billingApiKeyHint'
| 'billingAdvancedOverride'
/** English copy. */
export const en: Record<WebSearchSettingsLocaleKey, string> = {
@@ -42,7 +44,7 @@ export const en: Record<WebSearchSettingsLocaleKey, string> = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'A key is configured.',
count: 'Results per search',
countHint: 'Results returned per query: an integer from 1 to 50, default 10. This controls result count, not search calls. web_search keeps no more sources than its own result limit.',
countHint: 'Results returned per search: 1 to 50, default 10.',
overridden: 'Overridden',
reset: 'Reset to default',
readOnly: 'This deployment stores settings read-only.',
@@ -52,6 +54,14 @@ export const en: Record<WebSearchSettingsLocaleKey, string> = {
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
invalidCount: 'Enter an integer from 1 to 50, or leave blank to use the default.',
billing: 'Billing',
billingAuto: 'Automatic',
billingAutoHint: "Follows the conversation's DeepSeek account or API key; with other models, uses the API key first, then the signed-in account.",
billingAccount: 'Signed-in DeepSeek account',
billingAccountHint: 'Requires signing in to DeepSeek.',
billingApiKey: 'DeepSeek API key',
billingApiKeyHint: 'Uses the API key from the Models page; set one first.',
billingAdvancedOverride: 'Overridden by {fields}',
}
/** Simplified Chinese copy. */
@@ -81,7 +91,7 @@ export const zh: Record<WebSearchSettingsLocaleKey, string> = {
searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search',
apiKeySet: '已配置密钥。',
count: '每次搜索结果数',
countHint: '每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。',
countHint: '每次搜索返回的结果条数,1–50,默认 10。',
overridden: '已覆盖',
reset: '恢复默认',
readOnly: '本部署的设置为只读。',
@@ -91,6 +101,14 @@ export const zh: Record<WebSearchSettingsLocaleKey, string> = {
saveFailed: '本部署没有接受这些值,已保留供你修改。',
invalidNumber: '请填数字;留空表示使用默认值。',
invalidCount: '请填 1–50 的整数;留空表示使用默认值。',
billing: '计费方式',
billingAuto: '自动',
billingAutoHint: '跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。',
billingAccount: '已登录的 DeepSeek 账号',
billingAccountHint: '需要先登录 DeepSeek 账号。',
billingApiKey: 'DeepSeek API Key',
billingApiKeyHint: '使用「模型」页配置的 API Key,需要先配置。',
billingAdvancedOverride: '已被 {fields} 覆盖',
}
/**
@@ -129,7 +147,7 @@ export const ja = {
'searchGuideUrl': 'https://api-docs.deepseek.com/guides/search',
'apiKeySet': 'キーが設定されています。',
'count': '検索あたりの結果数',
'countHint': 'クエリごとに返される結果数: 1〜50 の整数、デフォルトは 10 です。これは結果数であり、検索呼び出し数ではありません。web_search は自身の結果上限を超えるソースを保持しません。',
'countHint': '検索ごとに返す結果数です。1〜50、既定値は 10 です。',
'overridden': '上書き済み',
'reset': 'デフォルトに戻す',
'readOnly': 'このデプロイでは設定は読み取り専用で保存されます。',
@@ -139,6 +157,14 @@ export const ja = {
'saveFailed': 'デプロイがこれらの値を受け入れませんでした。修正用に残されています。',
'invalidNumber': '数値を入力するか、デフォルトを使用するには空欄にしてください。',
'invalidCount': '1〜50 の整数を入力するか、デフォルトを使用するには空欄にしてください。',
'billing': '課金方法',
'billingAuto': '自動',
'billingAutoHint': '会話の DeepSeek アカウントまたは API キーに従います。他のモデルでは API キーを優先し、ない場合はログイン中のアカウントを使います。',
'billingAccount': 'ログイン中の DeepSeek アカウント',
'billingAccountHint': 'DeepSeek へのログインが必要です。',
'billingApiKey': 'DeepSeek API キー',
'billingApiKeyHint': 'モデルページの API キーを使います。先に設定してください。',
'billingAdvancedOverride': '{fields} により上書き済み',
} satisfies Record<keyof typeof en, string>
/** Spanish dictionary, checked against the English keys. */
@@ -168,7 +194,7 @@ export const es = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'Se configura una clave.',
count: 'Resultados por búsqueda',
countHint: 'Resultados devueltos por consulta: un número entero del 1 al 50, valor predeterminado 10. Esto controla el recuento de resultados, no las llamadas de búsqueda. web_search no guarda más fuentes que su propio límite de resultados.',
countHint: 'Resultados devueltos por búsqueda: de 1 a 50, predeterminado 10.',
overridden: 'Anulado',
reset: 'Restablecer los valores predeterminados',
readOnly: 'Esta implementación almacena configuraciones de solo lectura.',
@@ -178,6 +204,14 @@ export const es = {
saveFailed: 'El despliegue no aceptó estos valores; quedaron para que usted los corrigiera.',
invalidNumber: 'Ingrese un número o déjelo en blanco para usar el valor predeterminado.',
invalidCount: 'Ingrese un número entero del 1 al 50 o déjelo en blanco para usar el valor predeterminado.',
billing: 'Facturación',
billingAuto: 'Automática',
billingAutoHint: 'Sigue la cuenta o la clave de API de DeepSeek de la conversación; con otros modelos, usa primero la clave de API y luego la cuenta conectada.',
billingAccount: 'Cuenta de DeepSeek conectada',
billingAccountHint: 'Requiere iniciar sesión en DeepSeek.',
billingApiKey: 'Clave de API de DeepSeek',
billingApiKeyHint: 'Usa la clave de API de la página Modelos; configúrela primero.',
billingAdvancedOverride: 'Anulado por {fields}',
} satisfies Record<keyof typeof en, string>
/** French dictionary, checked against the English keys. */
@@ -207,7 +241,7 @@ export const fr = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'Une clé est configurée.',
count: 'Résultats par recherche',
countHint: 'Résultats renvoyés par requête : un nombre entier de 1 à 50, 10 par défaut. Cela contrôle le nombre de résultats, pas les appels de recherche. web_search ne conserve pas plus de sources que sa propre limite de résultats.',
countHint: 'Résultats renvoyés par recherche : de 1 à 50, 10 par défaut.',
overridden: 'Remplacé',
reset: 'Réinitialiser aux valeurs par défaut',
readOnly: 'Ce déploiement stocke les paramètres en lecture seule.',
@@ -217,6 +251,14 @@ export const fr = {
saveFailed: "Le déploiement n'a pas accepté ces valeurs ; ils vous ont été laissés à corriger.",
invalidNumber: 'Entrez un numéro ou laissez ce champ vide pour utiliser la valeur par défaut.',
invalidCount: 'Entrez un nombre entier compris entre 1 et 50 ou laissez vide pour utiliser la valeur par défaut.',
billing: 'Facturation',
billingAuto: 'Automatique',
billingAutoHint: "Suit le compte ou la clé API DeepSeek de la conversation ; avec d'autres modèles, utilise d'abord la clé API, puis le compte connecté.",
billingAccount: 'Compte DeepSeek connecté',
billingAccountHint: 'Nécessite une connexion à DeepSeek.',
billingApiKey: 'Clé API DeepSeek',
billingApiKeyHint: "Utilise la clé API de la page Modèles ; définissez-en une d'abord.",
billingAdvancedOverride: 'Remplacé par {fields}',
} satisfies Record<keyof typeof en, string>
/** German dictionary, checked against the English keys. */
@@ -246,7 +288,7 @@ export const de = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'Ein Schlüssel ist konfiguriert.',
count: 'Ergebnisse pro Suche',
countHint: 'Pro Abfrage zurückgegebene Ergebnisse: eine Ganzzahl von 1 bis 50, Standard 10. Dies steuert die Ergebnisanzahl, nicht Suchaufrufe. web_search behält nicht mehr Quellen als sein eigenes Ergebnislimit.',
countHint: 'Ergebnisse pro Suche: 1 bis 50, Standard 10.',
overridden: 'Überschrieben',
reset: 'Auf Standard zurücksetzen',
readOnly: 'Diese Bereitstellung speichert Einstellungen schreibgeschützt.',
@@ -256,6 +298,14 @@ export const de = {
saveFailed: 'Die Bereitstellung hat diese Werte nicht akzeptiert. Sie mussten sie korrigieren.',
invalidNumber: 'Geben Sie eine Zahl ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
invalidCount: 'Geben Sie eine Ganzzahl zwischen 1 und 50 ein oder lassen Sie das Feld leer, um den Standardwert zu verwenden.',
billing: 'Abrechnung',
billingAuto: 'Automatisch',
billingAutoHint: 'Folgt dem DeepSeek-Konto oder API-Schlüssel der Unterhaltung; bei anderen Modellen zuerst der API-Schlüssel, dann das angemeldete Konto.',
billingAccount: 'Angemeldetes DeepSeek-Konto',
billingAccountHint: 'Erfordert eine Anmeldung bei DeepSeek.',
billingApiKey: 'DeepSeek-API-Schlüssel',
billingApiKeyHint: 'Verwendet den API-Schlüssel der Seite Modelle; legen Sie zuerst einen fest.',
billingAdvancedOverride: 'Überschrieben durch {fields}',
} satisfies Record<keyof typeof en, string>
/** Russian dictionary, checked against the English keys. */
@@ -285,7 +335,7 @@ export const ru = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'Ключ настроен.',
count: 'Результаты по поиску',
countHint: 'Результаты, возвращаемые по запросу: целое число от 1 до 50, по умолчанию 10. Это контролирует количество результатов, а не поисковые вызовы. web_search хранит не больше источников, чем его собственный лимит результатов.',
countHint: 'Число результатов на поиск: от 1 до 50, по умолчанию 10.',
overridden: 'Переопределено',
reset: 'Сбросить настройки по умолчанию',
readOnly: 'В этом развертывании параметры сохраняются только для чтения.',
@@ -295,6 +345,14 @@ export const ru = {
saveFailed: 'Развертывание не приняло эти значения; их оставили вам на исправление.',
invalidNumber: 'Введите число или оставьте поле пустым, чтобы использовать значение по умолчанию.',
invalidCount: 'Введите целое число от 1 до 50 или оставьте поле пустым, чтобы использовать значение по умолчанию.',
billing: 'Оплата',
billingAuto: 'Автоматически',
billingAutoHint: 'Следует аккаунту или ключу API DeepSeek из разговора; с другими моделями сначала ключ API, затем активный аккаунт.',
billingAccount: 'Активный аккаунт DeepSeek',
billingAccountHint: 'Требуется вход в DeepSeek.',
billingApiKey: 'Ключ API DeepSeek',
billingApiKeyHint: 'Использует ключ API со страницы моделей; сначала задайте его.',
billingAdvancedOverride: 'Переопределено: {fields}',
} satisfies Record<keyof typeof en, string>
/** Portuguese dictionary, checked against the English keys. */
@@ -324,7 +382,7 @@ export const pt = {
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
apiKeySet: 'Uma chave está configurada.',
count: 'Resultados por pesquisa',
countHint: 'Resultados retornados por consulta: um número inteiro de 1 a 50, padrão 10. Isso controla a contagem de resultados, não as chamadas de pesquisa. web_search não mantém mais fontes do que seu próprio limite de resultados.',
countHint: 'Resultados retornados por pesquisa: de 1 a 50, padrão 10.',
overridden: 'Substituído',
reset: 'Redefinir para o padrão',
readOnly: 'Esta implantação armazena configurações somente leitura.',
@@ -334,4 +392,12 @@ export const pt = {
saveFailed: 'A implantação não aceitou esses valores; eles foram deixados para você corrigir.',
invalidNumber: 'Insira um número ou deixe em branco para usar o padrão.',
invalidCount: 'Insira um número inteiro de 1 a 50 ou deixe em branco para usar o padrão.',
billing: 'Cobrança',
billingAuto: 'Automática',
billingAutoHint: 'Segue a conta ou a chave de API da DeepSeek da conversa; com outros modelos, usa primeiro a chave de API e depois a conta conectada.',
billingAccount: 'Conta DeepSeek conectada',
billingAccountHint: 'Requer login na DeepSeek.',
billingApiKey: 'Chave de API da DeepSeek',
billingApiKeyHint: 'Usa a chave de API da página Modelos; defina uma primeiro.',
billingAdvancedOverride: 'Substituído por {fields}',
} satisfies Record<keyof typeof en, string>
@@ -12,9 +12,11 @@
* in registration order, plus a selected id the Host no longer registers. A
* failed read is reported on the page with a retry.
*
* The DeepSeek Search and Fetch APIs take no credential settings here: they
* follow the DeepSeek credential of the conversation, and their own endpoint
* fields are YAML-only. Legacy Messages search keeps its key control.
* The DeepSeek Search and Fetch APIs take no credential values here: each
* has a billing menu, written to `searchBilling` or `fetchBilling`, that
* chooses which DeepSeek credential it sends. Their own endpoint fields are
* YAML-only: the page reads them to report that they override billing and
* never writes them. Legacy Messages search keeps its key control.
*
* The legacy key's literal never rides a response, so the page learns only
* whether one is configured and writes it through the credentials domain,
@@ -95,10 +97,38 @@ export interface WebLegacySettings {
maxUses?: number
}
/** Credential a DeepSeek Search or Fetch API capability sends, as `web-deepseek-api` names it. */
export type WebBilling = 'auto' | 'account' | 'api-key'
/** The Search and Fetch API fields this page edits. */
export interface WebApiSettings {
/** Search API results per search. */
count?: number
/** Credential the Search API sends. */
searchBilling?: WebBilling
/** Credential the Fetch API sends. */
fetchBilling?: WebBilling
/** Search API base set in YAML; read only, and it overrides {@link searchBilling}. */
readonly searchBaseURL?: string
/** Search API credential reference set in YAML; read only, and it overrides {@link searchBilling}. */
readonly searchApiKeyEnv?: string
/** Fetch API base set in YAML; read only, and it overrides {@link fetchBilling}. */
readonly fetchBaseURL?: string
/** Fetch API credential reference set in YAML; read only, and it overrides {@link fetchBilling}. */
readonly fetchApiKeyEnv?: string
}
/** One capability's billing menu. */
export interface WebBillingState {
/** The staged billing; a section that carries none reads as `auto`. */
value: WebBilling
/** Whether saving would leave a user-layer entry for this field. */
overridden: boolean
/**
* Names of the capability's non-empty own endpoint fields, base URL first. While any is set, the provider ignores
* billing and the menu is locked.
*/
advancedOverride: readonly string[]
}
/** One entry of a provider menu. */
@@ -156,6 +186,10 @@ export interface WebSearchCardState extends SettingsFormShell {
fetch: WebProviderSectionState<WebFetchKind>
/** Search API results per search, shown in the Search section while it selects the DeepSeek Search API. */
count: SettingsFieldState
/** Search API billing, present only while the Search section selects the DeepSeek Search API. */
searchBilling: WebBillingState | undefined
/** Fetch API billing, present only while the Fetch section selects the DeepSeek Fetch API. */
fetchBilling: WebBillingState | undefined
/** Legacy Messages endpoint. */
messagesBaseURL: SettingsFieldState
/** Legacy server-side search limit. */
@@ -176,12 +210,25 @@ export interface WebSearchCardFace extends Pick<SettingsFormActions, 'save' | 'd
resetField: (field: WebSectionField) => void
/** Stage one provider selection; the empty id stages automatic selection. */
selectProvider: (field: keyof WebProviderSelection, id: string) => void
/** Stage one capability's billing; the composed billing clears the override. */
selectBilling: (capability: WebBillingCapability, billing: WebBilling) => void
/** Re-read the registered providers after a failed read. */
retryProviders: () => void
/** Resolve a provider description to the active language. */
resolveText: (text: NonNullable<WebProviderOption['description']>) => string
}
/** A capability whose DeepSeek API has a billing menu. */
type WebBillingCapability = 'search' | 'fetch'
/** Billing values the Host accepts. */
const BILLINGS: readonly string[] = ['auto', 'account', 'api-key'] satisfies readonly WebBilling[]
/** Whether `text` names a billing the Host accepts. */
function isBilling(text: string): text is WebBilling {
return BILLINGS.includes(text)
}
/** Settings scopes for the namespaces the page edits. */
export interface WebSearchCardScopes {
/** The web service's provider selection. */
@@ -251,6 +298,8 @@ export class WebSearchCardController {
return Number.isInteger(value) && value >= 1 && value <= 50 ? { kind: 'set', value } : undefined
},
},
// Drafts come only from selectBilling, which stages a billing the Host accepts.
settingsTextField('searchBilling'), settingsTextField('fetchBilling'),
])
this.legacy = new SettingsFormModel(scopes.legacy, [
settingsTextField('baseURL'),
@@ -412,12 +461,23 @@ export class WebSearchCardController {
auto: base?.fetchProvider === undefined,
},
count: this.api.field('count'),
searchBilling: this.searchKind() === 'deepseek' ? this.billing('search') : undefined,
fetchBilling: this.fetchKind() === 'deepseek' ? this.billing('fetch') : undefined,
messagesBaseURL: this.legacy.field('baseURL'),
maxUses: this.legacy.field('maxUses'),
legacyKey: this.keyShown() ? { ...this.selection.field(API_KEY_FIELD), ...this.credential } : undefined,
}
}
/** One capability's billing as its menu renders it. */
private billing(capability: WebBillingCapability): WebBillingState {
const field = this.api.field(`${capability}Billing`)
const section = this.scopes.api.getSnapshot().value
const advancedOverride = ([`${capability}BaseURL`, `${capability}ApiKeyEnv`] as const)
.filter(name => (section?.[name] ?? '') !== '')
return { value: isBilling(field.text) ? field.text : 'auto', overridden: field.overridden, advancedOverride }
}
/**
* Re-read the registered providers. Only the latest read publishes; a failed
* read keeps the previous list and marks the page's provider list as failed
@@ -528,6 +588,7 @@ export class WebSearchCardController {
form.actions().resetField(field === 'messagesBaseURL' ? 'baseURL' : field)
},
selectProvider: (field, id) => { this.select(field, id) },
selectBilling: (capability, billing) => { this.selectBilling(capability, billing) },
retryProviders: () => { this.refreshProviders() },
resolveText: text => this.ctx.locale.resolveText(text),
save: () => { void this.save() },
@@ -550,6 +611,19 @@ export class WebSearchCardController {
else this.selection.actions().edit(field, id)
}
/**
* Stage one capability's billing. A billing equal to the composed one, which
* is `auto` when the deployment composes none, clears the override.
* @param capability - the capability whose billing changes.
* @param billing - the billing to stage.
*/
private selectBilling(capability: WebBillingCapability, billing: WebBilling): void {
const field = `${capability}Billing` as const
const composed = (this.scopes.api.getSnapshot().base as WebApiSettings | undefined)?.[field] ?? 'auto'
if (composed === billing) this.api.actions().resetField(field)
else this.api.actions().edit(field, billing)
}
/**
* Save the API section before the selection, so a selection takes effect
* only after the settings it uses have landed. A refused section stops the
@@ -24,13 +24,13 @@ function field(text: string, rest: Partial<SettingsFieldState> = {}): SettingsFi
/** Each official provider's registered description; the page shows the active language's entry. */
const DESCRIPTIONS: Record<string, { en: string; zh: string }> = {
'deepseek-search-api': {
en: 'Search API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, and may incur API charges.',
zh: 'Search API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费',
en: 'Search API description. Incurs charges, billed to the DeepSeek account or DeepSeek API key that the Billing setting selects.',
zh: 'Search API 说明。会产生费用,按「计费方式」向 DeepSeek 账号或 DeepSeek API Key 计费。',
},
'http': { en: 'Direct fetch description.', zh: '本机获取说明' },
'deepseek-fetch-api': {
en: 'Fetch API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, may incur API charges.',
zh: 'Fetch API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费',
en: 'Fetch API description. Incurs charges, billed to the DeepSeek account or DeepSeek API key that the Billing setting selects.',
zh: 'Fetch API 说明。会产生费用,按「计费方式」向 DeepSeek 账号或 DeepSeek API Key 计费。',
},
}
@@ -54,6 +54,8 @@ function shipped(): WebSearchCardState {
search: { selected: 'deepseek-search-api', options: SEARCH_OPTIONS, kind: 'deepseek', auto: false },
fetch: { selected: 'http', options: FETCH_OPTIONS, kind: 'local', auto: false },
count: field('10'),
searchBilling: { value: 'auto', overridden: false, advancedOverride: [] },
fetchBilling: undefined,
messagesBaseURL: field(''),
maxUses: field('5'),
legacyKey: undefined,
@@ -63,7 +65,9 @@ function shipped(): WebSearchCardState {
/** Both DeepSeek API providers selected. */
function apis(): WebSearchCardState {
const base = shipped()
return { ...base, fetch: { ...base.fetch, selected: 'deepseek-fetch-api', kind: 'deepseek' } }
return {
...base, fetch: { ...base.fetch, selected: 'deepseek-fetch-api', kind: 'deepseek' }, fetchBilling: { value: 'account', overridden: true, advancedOverride: [] },
}
}
/** Legacy Messages search selected, with its key control. */
@@ -72,6 +76,7 @@ function legacy(key: Partial<NonNullable<WebSearchCardState['legacyKey']>> = {})
return {
...base,
search: { ...base.search, selected: 'deepseek-official', kind: 'legacy' },
searchBilling: undefined,
legacyKey: { ...field(''), ref: 'DEEPSEEK_API_KEY', configured: false, writable: true, ...key },
}
}
@@ -83,12 +88,14 @@ function plugins(): WebSearchCardState {
...base,
search: { selected: 'exa', kind: 'plugin', auto: false, options: [{ id: 'exa', title: 'Exa', missing: false }] },
fetch: { selected: '', kind: 'none', auto: false, options: [] },
searchBilling: undefined,
}
}
function cardActions() {
return {
edit: vi.fn(), resetField: vi.fn(), selectProvider: vi.fn(), retryProviders: vi.fn(), save: vi.fn(), discard: vi.fn(),
edit: vi.fn(), resetField: vi.fn(), selectProvider: vi.fn(), selectBilling: vi.fn(), retryProviders: vi.fn(), save: vi.fn(),
discard: vi.fn(),
}
}
@@ -107,6 +114,96 @@ const region = (name: string) => within(screen.getByRole('region', { name }))
/** The text of every paragraph in a section, in order. */
const paragraphs = (name: string) => [...screen.getByRole('region', { name }).querySelectorAll('p')].map(node => node.textContent)
/** The billing menu of a section, named by its label followed by the selected billing. */
const billingMenu = (name: string) => region(name).getByRole('button', { name: new RegExp(`^${en.billing} `) })
describe('WebSearchCard billing', () => {
it('shows each DeepSeek API billing menu with the selected billing and its explanation', () => {
renderPage(apis())
expect(billingMenu(en.searchSection).textContent).toBe('Automatic')
expect(billingMenu(en.fetchSection).textContent).toBe('Signed-in DeepSeek account')
expect(region(en.searchSection).getByText(
"Follows the conversation's DeepSeek account or API key; with other models, uses the API key first, then the signed-in account.",
{ exact: true },
)).toBeTruthy()
expect(region(en.fetchSection).getByText('Requires signing in to DeepSeek.', { exact: true }))
.toBeTruthy()
expect(screen.queryByText(/^Overridden by /)).toBeNull()
})
it.each<[string, readonly string[], string]>([
['one own field', ['searchBaseURL'], 'Overridden by searchBaseURL'],
// The page's resolver picks Chinese, so two names join with the Chinese enumeration comma.
['both own fields', ['searchBaseURL', 'searchApiKeyEnv'], 'Overridden by searchBaseURL、searchApiKeyEnv'],
])('tags the billing with %s and locks its menu', (_label, advancedOverride, tag) => {
renderPage({ ...apis(), searchBilling: { value: 'auto', overridden: false, advancedOverride } })
expect(region(en.searchSection).getByText(tag, { exact: true })).toBeTruthy()
expect((billingMenu(en.searchSection) as HTMLButtonElement).disabled).toBe(true)
expect(region(en.fetchSection).queryByText(/^Overridden by /)).toBeNull()
expect((billingMenu(en.fetchSection) as HTMLButtonElement).disabled).toBe(false)
})
it('joins the overriding field names with the separator of the active language', () => {
renderPage({ ...apis(), fetchBilling: { value: 'auto', overridden: false, advancedOverride: ['fetchBaseURL', 'fetchApiKeyEnv'] } })
expect(resolveText).toHaveBeenCalledWith(expect.objectContaining({ en: ', ', zh: '、' }))
expect(region(en.fetchSection).getByText('Overridden by fetchBaseURL、fetchApiKeyEnv', { exact: true })).toBeTruthy()
})
it('lists the three billings and selects a new one for its capability only', () => {
const { actions } = renderPage(apis())
fireEvent.click(billingMenu(en.fetchSection))
expect(screen.getAllByRole('menuitem').map(item => item.textContent))
.toEqual(['Automatic', 'Signed-in DeepSeek account', 'DeepSeek API key'])
fireEvent.click(screen.getByRole('menuitem', { name: 'DeepSeek API key' }))
fireEvent.click(billingMenu(en.searchSection))
fireEvent.click(screen.getByRole('menuitem', { name: 'Automatic' }))
expect(actions.selectBilling.mock.calls).toEqual([['fetch', 'api-key']])
})
it('explains API-key billing', () => {
renderPage({ ...shipped(), searchBilling: { value: 'api-key', overridden: true, advancedOverride: [] } })
expect(billingMenu(en.searchSection).textContent).toBe('DeepSeek API key')
expect(region(en.searchSection).getByText('Uses the API key from the Models page; set one first.', { exact: true }))
.toBeTruthy()
})
it('shows no billing menu for local fetch, legacy search, or plugin providers', () => {
renderPage(legacy())
expect(screen.queryByRole('button', { name: new RegExp(`^${en.billing} `) })).toBeNull()
cleanup()
renderPage(plugins())
expect(screen.queryByRole('button', { name: new RegExp(`^${en.billing} `) })).toBeNull()
})
it('locks the billing menus while settings are read-only or saving', () => {
renderPage({ ...apis(), writable: false })
expect((billingMenu(en.searchSection) as HTMLButtonElement).disabled).toBe(true)
expect((billingMenu(en.fetchSection) as HTMLButtonElement).disabled).toBe(true)
cleanup()
renderPage({ ...apis(), saving: true })
expect((billingMenu(en.fetchSection) as HTMLButtonElement).disabled).toBe(true)
})
it('carries the approved billing copy in both locales', () => {
expect([en.billing, en.billingAuto, en.billingAccount, en.billingApiKey])
.toEqual(['Billing', 'Automatic', 'Signed-in DeepSeek account', 'DeepSeek API key'])
expect([zh.billing, zh.billingAuto, zh.billingAccount, zh.billingApiKey])
.toEqual(['计费方式', '自动', '已登录的 DeepSeek 账号', 'DeepSeek API Key'])
expect([zh.billingAutoHint, zh.billingAccountHint, zh.billingApiKeyHint, zh.billingAdvancedOverride]).toEqual([
'跟随当前会话的 DeepSeek 账号或 API Key;会话用其他模型时,优先用 API Key,没有则用已登录账号。',
'需要先登录 DeepSeek 账号。',
'使用「模型」页配置的 API Key,需要先配置。',
'已被 {fields} 覆盖',
])
expect([en.billingAutoHint, en.billingAccountHint, en.billingApiKeyHint, en.billingAdvancedOverride]).toEqual([
"Follows the conversation's DeepSeek account or API key; with other models, uses the API key first, then the signed-in account.",
'Requires signing in to DeepSeek.',
'Uses the API key from the Models page; set one first.',
'Overridden by {fields}',
])
})
})
describe('WebSearchCard', () => {
it('offers legacy search in both locales and renders its own settings', () => {
const { actions } = renderPage(legacy())
@@ -167,14 +264,16 @@ describe('WebSearchCard', () => {
expect(region(en.fetchSection).queryByRole('textbox')).toBeNull()
})
it('shows the DeepSeek Fetch API description under its provider menu', () => {
it('shows the DeepSeek Fetch API description under its provider menu, and its billing panel after it', () => {
renderPage(apis())
expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch)
expect(region(en.fetchSection).getByText(DESCRIPTIONS['deepseek-fetch-api']!.zh)).toBeTruthy()
expect(screen.getAllByRole('button').filter(button => button.hasAttribute('aria-expanded')).map(button => button.id))
.toEqual(['plugin-config-web-search-provider', 'plugin-config-web-fetch-provider'])
// The Fetch section holds only its title and provider menu block.
expect(screen.getByRole('region', { name: en.fetchSection }).children).toHaveLength(2)
expect(screen.getAllByRole('button').filter(button => button.hasAttribute('aria-expanded')).map(button => button.id)).toEqual([
'plugin-config-web-search-provider', 'plugin-config-web-search-billing',
'plugin-config-web-fetch-provider', 'plugin-config-web-fetch-billing',
])
// The Fetch section holds its title, its provider menu block, and the billing panel.
expect(screen.getByRole('region', { name: en.fetchSection }).children).toHaveLength(3)
expect(region(en.fetchSection).queryByRole('textbox')).toBeNull()
})
@@ -591,6 +591,101 @@ describe('WebSearchCardController sections', () => {
})
})
describe('WebSearchCardController billing', () => {
it('shows each billing only while its DeepSeek API is selected, reading an unset billing as automatic', async () => {
const b = bench()
try {
serve(b)
await vi.waitFor(() => { expect(b.state().search.kind).toBe('deepseek') })
expect(b.state()).toMatchObject({ searchBilling: { value: 'auto', overridden: false }, fetchBilling: undefined })
b.face.selectProvider('fetchProvider', 'deepseek-fetch-api')
b.face.selectProvider('searchProvider', 'deepseek-official')
expect(b.state()).toMatchObject({ searchBilling: undefined, fetchBilling: { value: 'auto', overridden: false } })
} finally { b.controller.dispose() }
})
it('reads a stored billing and its override, and an unknown stored value as automatic', async () => {
const b = bench()
try {
serve(b, { value: API_SELECTION, base: API_SELECTION })
b.api.publish({ value: { count: 10, searchBilling: 'account', fetchBilling: 'session' as never }, user: { searchBilling: 'account' } })
await vi.waitFor(() => { expect(b.state().fetch.kind).toBe('deepseek') })
expect(b.state()).toMatchObject({
searchBilling: { value: 'account', overridden: true }, fetchBilling: { value: 'auto' },
})
} finally { b.controller.dispose() }
})
it('saves a selected billing and clears it when the composed billing is selected again', async () => {
const b = bench({ accept: true })
try {
serve(b, { value: API_SELECTION, base: API_SELECTION })
await vi.waitFor(() => { expect(b.state().fetch.kind).toBe('deepseek') })
b.face.selectBilling('search', 'api-key')
expect(b.state()).toMatchObject({ dirty: true, searchBilling: { value: 'api-key', overridden: true } })
b.face.save()
await vi.waitFor(() => { expect(b.api.mutate).toHaveBeenCalledTimes(1) })
expect(b.api.mutate).toHaveBeenCalledWith([{ op: 'set', path: ['searchBilling'], value: 'api-key' }], undefined)
await vi.waitFor(() => { expect(b.state().saving).toBe(false) })
b.face.selectBilling('search', 'auto')
expect(b.state().searchBilling).toEqual({ value: 'auto', overridden: false, advancedOverride: [] })
b.face.save()
await vi.waitFor(() => { expect(b.api.mutate).toHaveBeenCalledTimes(2) })
expect(b.api.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['searchBilling'] }], undefined)
} finally { b.controller.dispose() }
})
it('stores automatic billing as an override where the deployment composes another billing', async () => {
const b = bench({ accept: true })
try {
serve(b, { value: API_SELECTION, base: API_SELECTION }, { count: 10, fetchBilling: 'account' })
await vi.waitFor(() => { expect(b.state().fetchBilling).toMatchObject({ value: 'account' }) })
b.face.selectBilling('fetch', 'account')
expect(b.state().dirty).toBe(false)
b.face.selectBilling('fetch', 'auto')
b.face.save()
await vi.waitFor(() => { expect(b.api.mutate).toHaveBeenCalledTimes(1) })
expect(b.api.mutate).toHaveBeenCalledWith([{ op: 'set', path: ['fetchBilling'], value: 'auto' }], undefined)
} finally { b.controller.dispose() }
})
})
describe('WebSearchCardController advanced override', () => {
it('names each capability’s non-empty own endpoint fields, base URL first, and none while they are unset or empty', async () => {
const b = bench()
try {
serve(b, { value: API_SELECTION, base: API_SELECTION })
await vi.waitFor(() => { expect(b.state().fetch.kind).toBe('deepseek') })
expect(b.state()).toMatchObject({ searchBilling: { advancedOverride: [] }, fetchBilling: { advancedOverride: [] } })
b.api.publish({ value: { count: 10, searchApiKeyEnv: 'OWN_KEY', searchBaseURL: 'https://own.test/v1/tools', fetchBaseURL: '' } })
expect(b.state().searchBilling?.advancedOverride).toEqual(['searchBaseURL', 'searchApiKeyEnv'])
expect(b.state().fetchBilling?.advancedOverride).toEqual([])
b.api.publish({ value: { count: 10, fetchApiKeyEnv: 'OWN_KEY' } })
expect(b.state().searchBilling?.advancedOverride).toEqual([])
expect(b.state().fetchBilling?.advancedOverride).toEqual(['fetchApiKeyEnv'])
} finally { b.controller.dispose() }
})
it('saves a billing without writing or clearing the own endpoint fields', async () => {
const b = bench({ accept: true })
try {
const composed = { count: 10, searchBaseURL: 'https://own.test/v1/tools', fetchApiKeyEnv: 'OWN_KEY' }
serve(b, { value: API_SELECTION, base: API_SELECTION }, composed)
await vi.waitFor(() => { expect(b.state().fetch.kind).toBe('deepseek') })
b.face.selectBilling('fetch', 'api-key')
b.face.edit('count', '20')
b.face.save()
await vi.waitFor(() => { expect(b.api.mutate).toHaveBeenCalledTimes(1) })
const ops = b.api.mutate.mock.calls[0]![0]
expect(ops.map(op => op.path[0]).sort()).toEqual(['count', 'fetchBilling'])
await vi.waitFor(() => { expect(b.state().saving).toBe(false) })
expect(b.state()).toMatchObject({
searchBilling: { advancedOverride: ['searchBaseURL'] }, fetchBilling: { value: 'api-key', advancedOverride: ['fetchApiKeyEnv'] },
})
} finally { b.controller.dispose() }
})
})
describe('WebSearchCardController providers', () => {
it('lists the official providers first in a fixed order and plugin providers after them in Host order', async () => {
const b = bench()
@@ -70,7 +70,7 @@
"picomatch": "^4.0.7",
"clsx": "^2.0.0",
"@deepseek-ai/dsh-util-crypto": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-client-ui-context-menu": "workspace:*"
+1 -1
View File
@@ -99,7 +99,7 @@
],
"dependencies": {
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/schemastery": "workspace:~"
}
}
+1 -1
View File
@@ -39,7 +39,7 @@
"react-dom": "^18.2.0",
"typescript": "^6.0.3",
"@deepseek-ai/dsh-host-webserver": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@tanstack/react-virtual": "3.14.10"
},
"peerDependencies": {
@@ -32,7 +32,7 @@
],
"license": "MIT",
"dependencies": {
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -53,7 +53,7 @@
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+1 -1
View File
@@ -34,7 +34,7 @@
"dependencies": {
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
+1 -1
View File
@@ -33,7 +33,7 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:*",
+1 -1
View File
@@ -45,7 +45,7 @@
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"devDependencies": {
"@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*",
+1 -1
View File
@@ -66,6 +66,6 @@
},
"dependencies": {
"@deepseek-ai/dsh-brand": "workspace:*",
"zod": "4.4.3"
"zod": "4.6.6"
}
}
@@ -52,6 +52,6 @@
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
}
}
+1 -1
View File
@@ -65,7 +65,7 @@
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-home-paths": "workspace:*"
}
}
@@ -60,7 +60,7 @@
"@deepseek-ai/dsh-util-values": "workspace:*"
},
"dependencies": {
"zod": "4.4.3"
"zod": "4.6.6"
},
"files": [
"locale/*.json",
@@ -51,7 +51,7 @@
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -18,8 +18,8 @@
en: de47f71ab56fd0ed
zh: fdc58f273a7dba3d
/deepseek-ai-dsh-experimental-browser-use-stagehand-native/use-this-package/minimal-configuration:
en: 1554d55fd5be9735
zh: d39e7dd0b8ff1b7d
en: 80f10a24e30b6d82
zh: 26d3f5e350715a38
/deepseek-ai-dsh-experimental-browser-use-stagehand-native/use-this-package/verification:
en: fea3ad16a77fc478
zh: cf551a8a2d183f93
@@ -50,7 +50,6 @@ Install a Chrome or Chromium executable that supports the pinned Stagehand SDK.
|---|---|---|
| `mode` | `launch` | Start a fresh browser or `attach` to an existing browser. |
| `cdpEndpoint` | Required for `attach` | HTTP or WebSocket debugging endpoint selected by the profile. |
| `extensionId` | Load bundled extension | Installed Stagehand extension to use in an existing browser. |
| `executablePath` | Installed stable Chrome | Chrome or Chromium executable, for `launch` only. |
| `headless` | `true` | Hide a launched browser's window. |
| `operationTimeoutMs` | `30000` | Deadline for Chromium startup, navigation, and natural-language actions. |
@@ -50,7 +50,6 @@ kind: "package-reference"
|---|---|---|
| `mode` | `launch` | 启动独立浏览器,或以 `attach` 连接现有浏览器。 |
| `cdpEndpoint` | `attach` 必填 | 由 profile 选择的 HTTP 或 WebSocket 调试端点。 |
| `extensionId` | 加载内置扩展 | 在现有浏览器中使用的已安装 Stagehand 扩展。 |
| `executablePath` | 已安装的稳定版 Chrome | Chrome 或 Chromium 可执行程序,仅适用于 `launch`。 |
| `headless` | `true` | 隐藏启动的浏览器窗口。 |
| `operationTimeoutMs` | `30000` | Chromium 启动、导航和自然语言操作的超时时间。 |
@@ -44,12 +44,12 @@
"@deepseek-ai/dsh-tools": "workspace:*"
},
"dependencies": {
"@browserbasehq/stagehand": "4.1.0",
"@browserbasehq/stagehand": "4.4.0",
"@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*",
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@puppeteer/browsers": "3.2.2"
},
@@ -32,8 +32,6 @@ export interface Config {
mode: 'launch' | 'attach'
/** CDP HTTP or WebSocket endpoint, required only for attach mode. */
cdpEndpoint?: string
/** Optional Stagehand extension id for an existing browser. */
extensionId?: string
/** Installed Chrome/Chromium executable used in launch mode. */
executablePath?: string
/** Hide an owned browser's window. */
@@ -55,7 +53,6 @@ export const Config: Schema<Config, ResolvedConfig> = Schema.object({
}).required(), value => stagehandModelSchema.parse(value)).required(),
mode: Schema.union(['launch', 'attach']).default('launch'),
cdpEndpoint: Schema.string(),
extensionId: Schema.string(),
executablePath: Schema.string(),
headless: Schema.boolean().default(true),
// Stagehand adds ten seconds to the action RPC timeout before arming its timer.
@@ -86,8 +83,8 @@ export function apply(ctx: Context, input: Config): void {
if (config.mode === 'attach' && !config.cdpEndpoint?.trim()) {
throw new Error('Stagehand attach mode requires cdpEndpoint')
}
if (config.mode === 'launch' && (config.cdpEndpoint !== undefined || config.extensionId !== undefined)) {
throw new Error('Stagehand cdpEndpoint and extensionId require attach mode')
if (config.mode === 'launch' && config.cdpEndpoint !== undefined) {
throw new Error('Stagehand cdpEndpoint requires attach mode')
}
if (config.mode === 'attach' && config.executablePath !== undefined) {
throw new Error('Stagehand executablePath requires launch mode')
@@ -106,7 +103,6 @@ export function apply(ctx: Context, input: Config): void {
const connect = (connectionSignal: AbortSignal) => openBrowserWorker({
mode: 'attach', model: config.model, headless: config.headless,
operationTimeoutMs: config.operationTimeoutMs, shutdownGraceMs: config.shutdownGraceMs,
...config.extensionId === undefined ? {} : { extensionId: config.extensionId },
...config.cdpEndpoint === undefined ? {} : { cdpEndpoint: config.cdpEndpoint },
...chromium === undefined ? {} : { cdpEndpoint: chromium.endpoint },
}, connectionSignal, (message) => { ctx.logger.warn(message) })
@@ -38,8 +38,6 @@ export interface NativeBrowserConfig {
mode: 'launch' | 'attach'
/** Existing browser's configured debugging endpoint. */
cdpEndpoint?: string
/** Optional existing Stagehand extension id. */
extensionId?: string
/** Executable selected for an owned Chromium instance. */
executablePath?: string
/** Whether to hide an owned browser's window. */
@@ -96,7 +94,6 @@ export async function openNativeBrowser(config: NativeBrowserConfig): Promise<Na
const { Stagehand, localBrowser } = await import('@browserbasehq/stagehand')
const browser = await localBrowser.connect({
cdpUrl: z.string().parse(config.cdpEndpoint),
...config.extensionId === undefined ? {} : { extensionId: config.extensionId },
})
const stagehand = await Stagehand.create({ browser, model: config.model, logging: { level: 'off' } })
return {
@@ -7,12 +7,11 @@ import { answer } from './worker-rpc.ts'
const port = parentPort
if (port === null) throw new Error('Stagehand attachment requires a Worker parent')
const { extensionId, cdpEndpoint, executablePath, ...config } = z.object({
const { cdpEndpoint, executablePath, ...config } = z.object({
model: stagehandModelSchema,
mode: z.enum(['launch', 'attach']),
cdpEndpoint: z.string().optional(),
executablePath: z.string().optional(),
extensionId: z.string().optional(),
headless: z.boolean(),
operationTimeoutMs: z.number().int().positive(),
shutdownGraceMs: z.number().int().positive(),
@@ -20,7 +19,7 @@ const { extensionId, cdpEndpoint, executablePath, ...config } = z.object({
const methodSchema = z.enum(Object.keys(browserInputs) as [keyof typeof browserInputs, ...Array<keyof typeof browserInputs>])
const opening = openNativeBrowser(
{
...config, ...extensionId === undefined ? {} : { extensionId },
...config,
...cdpEndpoint === undefined ? {} : { cdpEndpoint },
...executablePath === undefined ? {} : { executablePath },
},
@@ -117,11 +117,11 @@ it('lazily launches distinct browsers and preserves each Session tab state', asy
})
it('reserves an attached browser for one live owner and rejects model connection overrides', async () => {
await ctx.plugin(Provider, { model: nativeModel, mode: 'attach', cdpEndpoint: 'http://localhost:9222', extensionId: 'fixture-extension' })
await ctx.plugin(Provider, { model: nativeModel, mode: 'attach', cdpEndpoint: 'http://localhost:9222' })
expect((await execute(first, 'tabs', { action: 'list' })).isError).toBe(false)
expect((await execute(second, 'tabs', { action: 'list' })).isError).toBe(true)
expect(fixture.browsers).toHaveLength(1)
expect(fixture.browsers[0]?.options).toEqual({ cdpUrl: 'http://localhost:9222', extensionId: 'fixture-extension' })
expect(fixture.browsers[0]?.options).toEqual({ cdpUrl: 'http://localhost:9222' })
expect((await execute(first, 'navigate', { url: 'https://example.com', cdpEndpoint: 'http://other' })).isError).toBe(true)
expect((await execute(first, 'act', { instruction: 'click', model: 'other' })).isError).toBe(true)
})
@@ -58,7 +58,7 @@
"@deepseek-ai/dsh-util-crypto": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"zod": "^4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -60,7 +60,7 @@
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "^4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
},
"devDependencies": {
@@ -50,7 +50,7 @@
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-action-cards": "workspace:*",
"@deepseek-ai/dsh-action-cards": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
"@deepseek-ai/dsh-llm": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*"
@@ -67,7 +67,7 @@
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-util-values": "workspace:*",
"diff": "^9.0.0",
"zod": "^4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-storage-domain": "workspace:*"
},
@@ -57,7 +57,7 @@
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/dsh-client-ui-action-cards": "workspace:*",
"@deepseek-ai/dsh-action-cards": "workspace:*",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-client-test-runtime": "workspace:*"
},
"files": [
@@ -41,7 +41,7 @@
"@deepseek-ai/dsh-mcp-client": "workspace:*",
"@deepseek-ai/schemastery": "workspace:~",
"@trycua/cua-driver": "0.28.0",
"zod": "4.4.3"
"zod": "4.6.6"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -54,7 +54,7 @@
"dependencies": {
"yaml": "^2.9.0",
"resolve.exports": "^2.0.3",
"zod": "4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-client-ui-creation-recommendations": "workspace:*",
"@deepseek-ai/dsh-action-cards": "workspace:*",
"@deepseek-ai/dsh-client-ui-action-cards": "workspace:*"
+1 -1
View File
@@ -57,7 +57,7 @@
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "^4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-cua": "workspace:*"
},
+1 -1
View File
@@ -83,7 +83,7 @@
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"zod": "^4.4.3",
"zod": "4.6.6",
"@deepseek-ai/dsh-atomic-write": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-computer-use": "workspace:*",
@@ -35,7 +35,7 @@
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-timeout": "workspace:*",
"execa": "^10.0.1",
"zod": "^4.4.3"
"zod": "4.6.6"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more