mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge pull request #7565 from deepseek-harness/feat/web-search-fetch-settings
feat(web): 网页搜索与获取设置页分开选择 Search 与 Fetch,Search 默认改为 DeepSeek Search API
This commit is contained in:
@@ -6,23 +6,23 @@
|
||||
en: ca120635fc40431c
|
||||
zh: 6be7c5ae3c86cdcc
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/problem:
|
||||
en: c8b932b02a4e1c80
|
||||
zh: 96c466be1a521f3f
|
||||
en: df1e82a74c42fa1d
|
||||
zh: 8d5452360a0dec8a
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision:
|
||||
en: 2e08d801099cbe18
|
||||
zh: 8c5ab162ada9cc41
|
||||
en: 7a4921d82cd11749
|
||||
zh: 6ab506109afc1351
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology:
|
||||
en: 5ea3ddfae82df181
|
||||
zh: 2d05d95e51081696
|
||||
en: 3394139c17d28ba8
|
||||
zh: 66857733ba51d98c
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/ctx-web-contract:
|
||||
en: b432f80a424c3e49
|
||||
zh: faa26925fdd98387
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection:
|
||||
en: 4e4ec068c2a22383
|
||||
zh: b7030d0f00bfdb74
|
||||
en: afc540d986e00d68
|
||||
zh: 84795770d00228c3
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema:
|
||||
en: ec3703b7e7c6b23d
|
||||
zh: 325755e6e90092da
|
||||
en: ccf6404ae7f3172e
|
||||
zh: 3253035ea49e2548
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
|
||||
en: 6dba3b9a51c28b6d
|
||||
zh: 58a93f6ba2d269ea
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 3440f2b34b93f4a1
|
||||
zh: 57a8f784c7e26eff
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/testing:
|
||||
en: 7f73699ac4459432
|
||||
zh: c16be49ac50c34c8
|
||||
en: 03da2f3422183384
|
||||
zh: 10fede51a415ce11
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered:
|
||||
en: 6119e2aff3b6be5a
|
||||
zh: 76136ef5249c23eb
|
||||
@@ -42,8 +42,8 @@
|
||||
en: e0d84a9b1b9c2123
|
||||
zh: 9e116a7b96751563
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/put-provider-dispatch-directly-in-dsh-tool-web:
|
||||
en: dc01d014d03b49d2
|
||||
zh: b956b02f41c62682
|
||||
en: 68ef45e8e5a36de9
|
||||
zh: 0013786ed77e03a8
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/split-search-and-fetch-into-two-seams-dsh-search-dsh-fetch:
|
||||
en: 3fb72ba3e554f20a
|
||||
zh: aff5f0add5773388
|
||||
@@ -63,11 +63,11 @@
|
||||
en: 3ea94ffb142d144a
|
||||
zh: 89645f68412f6769
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
|
||||
en: 9774448fd6b21c41
|
||||
zh: 8411c590ad70d5c4
|
||||
en: 8e7523b2858efaf3
|
||||
zh: aff8fc74eb439ed6
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
|
||||
en: 48b7b8e7310cd56b
|
||||
zh: d406cd6a8a649e58
|
||||
en: ddb599fd2ac1647c
|
||||
zh: c0b8f446cf483b3c
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/open-questions:
|
||||
en: 63848c6ef9a6ab44
|
||||
zh: 041b340802091e60
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
|
||||
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: search APIs return deliberately different formats (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`, Perplexity returns a generated answer plus citations, and the DeepSeek Search API returns a flat `results[]` with snippets and summaries), and the normalized web contract must accept each of them without mirroring one vendor. The seam was designed against the Exa and Perplexity formats; neither provider ships, and a plugin can register one. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
|
||||
|
||||
The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
|
||||
|
||||
@@ -19,7 +19,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs`
|
||||
Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md):
|
||||
|
||||
1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors.
|
||||
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-http`.
|
||||
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-deepseek`, `@deepseek-ai/dsh-web-deepseek-api`, and `@deepseek-ai/dsh-web-fetch-http`.
|
||||
3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`.
|
||||
|
||||
Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
|
||||
@@ -32,9 +32,9 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov
|
||||
- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
|
||||
- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
|
||||
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` or `web_fetch` while the selected provider reports that the request's operation cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
|
||||
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
|
||||
The seam deliberately exposes no availability observation — no aggregated capability-status query and no per-provider availability report. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. Provider pickers consume registration, not availability: `listProviders()` and the Remote method `web/listProviders` list every registered provider with its optional `title` and `description` (including unavailable providers), and the payload-free `web/providers-changed` event fires after each registry mutation. The [web provider selection decision](2026-10-29-web-provider-selection.md) records why registration is observable while availability is not.
|
||||
|
||||
## Package topology
|
||||
|
||||
@@ -43,11 +43,9 @@ The three-package Service Definition / Service Provider / Consumer split follows
|
||||
The dependency direction mirrors bash and filesystem:
|
||||
|
||||
```text
|
||||
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
|
||||
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek
|
||||
consumer interface implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
|
||||
implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
|
||||
<--depends on-- @deepseek-ai/dsh-web-deepseek-api
|
||||
implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-fetch-http
|
||||
implementation
|
||||
@@ -57,9 +55,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
|
||||
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
|
||||
@@ -67,9 +63,9 @@ flowchart LR
|
||||
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support, including the Typert protocol for its Remote method. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
|
||||
Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
|
||||
Provider packages depend on `dsh-web`, Cordis, and their credential/configuration helpers. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
|
||||
|
||||
`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages.
|
||||
|
||||
@@ -78,16 +74,22 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
|
||||
`ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmRuntime`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
|
||||
|
||||
```ts
|
||||
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type {
|
||||
WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult,
|
||||
} from '@deepseek-ai/dsh-web'
|
||||
|
||||
interface WebSearchProvider {
|
||||
readonly id: string
|
||||
readonly title?: string
|
||||
readonly description?: WebProviderInfo['description']
|
||||
available(): boolean
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
}
|
||||
|
||||
interface WebFetchProvider {
|
||||
readonly id: string
|
||||
readonly title?: string
|
||||
readonly description?: WebProviderInfo['description']
|
||||
available(): boolean
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
}
|
||||
@@ -95,6 +97,7 @@ interface WebFetchProvider {
|
||||
interface WebRuntime {
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
listProviders(): WebProviderList
|
||||
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
@@ -131,18 +134,15 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: exa
|
||||
searchProvider: deepseek-search-api
|
||||
fetchProvider: http
|
||||
|
||||
- id: web-search-exa
|
||||
name: '@deepseek-ai/dsh-web-search-exa'
|
||||
|
||||
- id: web-search-perplexity
|
||||
name: '@deepseek-ai/dsh-web-search-perplexity'
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
|
||||
- id: web-deepseek-api
|
||||
name: '@deepseek-ai/dsh-web-deepseek-api'
|
||||
|
||||
- id: web-fetch-http
|
||||
name: '@deepseek-ai/dsh-web-fetch-http'
|
||||
|
||||
@@ -150,9 +150,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
```
|
||||
|
||||
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
|
||||
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` is equivalent to config `searchProvider: deepseek-search-api`, not a hidden priority chain inside `dsh-tool-web`.
|
||||
|
||||
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once.
|
||||
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Search and Fetch menus on the Web search and fetch settings page rely on this to switch each capability's provider independently, including the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md); the [web provider selection decision](2026-10-29-web-provider-selection.md) owns the shipped defaults.
|
||||
|
||||
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
|
||||
|
||||
@@ -168,8 +168,8 @@ The `web_search` model-facing tool is small. The only model-facing argument is:
|
||||
|
||||
- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`.
|
||||
- `ctx.web` passes the request through to the selected provider unchanged.
|
||||
- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization.
|
||||
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
|
||||
- A provider applies `maxResults` at the request layer when its API supports it (the DeepSeek Search API's `count`), as a cost/latency optimization.
|
||||
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
|
||||
|
||||
The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly.
|
||||
|
||||
@@ -190,13 +190,14 @@ interface WebSearchSource {
|
||||
readonly url: string
|
||||
readonly title?: string
|
||||
readonly snippet?: string
|
||||
readonly summary?: string
|
||||
readonly publishedAt?: string
|
||||
}
|
||||
```
|
||||
|
||||
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
|
||||
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, summary, and `publishedAt` are optional because not every provider returns them. `snippet` is a short excerpt for display, and `summary` is a longer excerpt for model reading whose length the consumer bounds. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — the DeepSeek Search API returns it as a nullable `date` on each result, Exa as `publishedDate`, and Perplexity as `date`, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
|
||||
|
||||
Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
|
||||
The DeepSeek Search API provider maps each entry of the response's `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `snippet`, `summary` ← `summary`, `publishedAt` ← `date`, omitting empty values. It returns no provider-generated answer, so `content` is omitted. If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
|
||||
|
||||
Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies.
|
||||
|
||||
@@ -283,7 +284,7 @@ Tool execution lets these errors flow through `ToolRuntime.execute()`, which alr
|
||||
|
||||
## Testing
|
||||
|
||||
Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
|
||||
Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -293,7 +294,7 @@ This matches the most flexible provider-plugin systems: every provider can expos
|
||||
|
||||
### Put provider dispatch directly in `dsh-tool-web`
|
||||
|
||||
This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema.
|
||||
This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add providers with different formats without baking their differences into the tool schema.
|
||||
|
||||
### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`)
|
||||
|
||||
@@ -321,9 +322,9 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary
|
||||
|
||||
## Consequences
|
||||
|
||||
**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
|
||||
**The search schema is deliberately thin.** Search APIs expose useful provider-specific controls, such as Exa's `livecrawl`, Perplexity's search recency, and the DeepSeek Search API's `localization`; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
|
||||
|
||||
**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
|
||||
**Citations can be sparse.** A Perplexity-style citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
|
||||
|
||||
**Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
|
||||
|
||||
@@ -337,7 +338,7 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary
|
||||
|
||||
- A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled.
|
||||
- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently.
|
||||
- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly.
|
||||
- Provider-neutral search controls beyond `query` and `maxResults`, once the registered providers can honor them honestly.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 web 约定并非只是镜像某一家厂商。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
|
||||
harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:各搜索 API 返回刻意不同的格式(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表;DeepSeek Search API 返回带 snippet 与摘要的扁平 `results[]`),归一化的 web 约定必须能接纳每一种格式,而不是镜像某一家厂商。seam 是以 Exa 和 Perplexity 的格式为参照设计的;这两个提供方都不随附,插件可以注册它们。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
|
||||
|
||||
面向模型的 API 必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。
|
||||
|
||||
@@ -19,7 +19,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某
|
||||
Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md):
|
||||
|
||||
1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。
|
||||
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。
|
||||
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-deepseek`、`@deepseek-ai/dsh-web-deepseek-api` 和 `@deepseek-ai/dsh-web-fetch-http`。
|
||||
3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。
|
||||
|
||||
提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。
|
||||
@@ -32,9 +32,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
|
||||
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
|
||||
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。
|
||||
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。`dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的操作无法鉴权时,它会[撤下 `web_search` 或 `web_fetch`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
|
||||
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
|
||||
该 seam 刻意不暴露可用性观察——没有聚合的能力状态查询,也没有逐个提供方的可用性报告。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。提供方选择器消费的是注册情况而非可用性:`listProviders()` 与 Remote 方法 `web/listProviders` 列出每个已注册提供方及其可选的 `title` 与 `description`(包括不可用的提供方),且每次注册表变更后都会触发无载荷的 `web/providers-changed` 事件。[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)记录了为何注册情况可观察而可用性不可观察。
|
||||
|
||||
## 包拓扑
|
||||
|
||||
@@ -43,11 +43,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
|
||||
依赖方向与 bash 和 filesystem 一致:
|
||||
|
||||
```text
|
||||
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
|
||||
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek
|
||||
consumer interface implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
|
||||
implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
|
||||
<--depends on-- @deepseek-ai/dsh-web-deepseek-api
|
||||
implementation
|
||||
<--depends on-- @deepseek-ai/dsh-web-fetch-http
|
||||
implementation
|
||||
@@ -57,9 +55,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
|
||||
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
|
||||
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
|
||||
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
|
||||
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
|
||||
@@ -67,9 +63,9 @@ flowchart LR
|
||||
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。
|
||||
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持,其中包括其 Remote 方法所用的 Typert 协议。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。
|
||||
|
||||
提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
|
||||
提供方包依赖 `dsh-web`、Cordis 及其凭据和配置函数。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
|
||||
|
||||
`@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。
|
||||
|
||||
@@ -78,16 +74,22 @@ flowchart LR
|
||||
`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行 API。注册表部分与 `LlmRuntime` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状:
|
||||
|
||||
```ts
|
||||
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
import type {
|
||||
WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult,
|
||||
} from '@deepseek-ai/dsh-web'
|
||||
|
||||
interface WebSearchProvider {
|
||||
readonly id: string
|
||||
readonly title?: string
|
||||
readonly description?: WebProviderInfo['description']
|
||||
available(): boolean
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
}
|
||||
|
||||
interface WebFetchProvider {
|
||||
readonly id: string
|
||||
readonly title?: string
|
||||
readonly description?: WebProviderInfo['description']
|
||||
available(): boolean
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
}
|
||||
@@ -95,6 +97,7 @@ interface WebFetchProvider {
|
||||
interface WebRuntime {
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
listProviders(): WebProviderList
|
||||
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
@@ -131,18 +134,15 @@ interface WebRuntime {
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: exa
|
||||
searchProvider: deepseek-search-api
|
||||
fetchProvider: http
|
||||
|
||||
- id: web-search-exa
|
||||
name: '@deepseek-ai/dsh-web-search-exa'
|
||||
|
||||
- id: web-search-perplexity
|
||||
name: '@deepseek-ai/dsh-web-search-perplexity'
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
|
||||
- id: web-deepseek-api
|
||||
name: '@deepseek-ai/dsh-web-deepseek-api'
|
||||
|
||||
- id: web-fetch-http
|
||||
name: '@deepseek-ai/dsh-web-fetch-http'
|
||||
|
||||
@@ -150,9 +150,9 @@ interface WebRuntime {
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
```
|
||||
|
||||
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
|
||||
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` 等同于配置 `searchProvider: deepseek-search-api`,而非 `dsh-tool-web` 内部的隐式优先级链。
|
||||
|
||||
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。
|
||||
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即可让下一次操作选用新提供方。「网页搜索与获取」设置页的 Search 和 Fetch 下拉框借此分别切换各能力的提供方(包括 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md));[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)负责内置默认值。
|
||||
|
||||
`ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
|
||||
|
||||
@@ -168,8 +168,8 @@ interface WebRuntime {
|
||||
|
||||
- `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。
|
||||
- `ctx.web` 将请求原样传递给选定的提供方。
|
||||
- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
|
||||
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
|
||||
- 当提供方的 API 支持结果数量控制时(DeepSeek Search API 的 `count`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
|
||||
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
|
||||
|
||||
seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。
|
||||
|
||||
@@ -190,13 +190,14 @@ interface WebSearchSource {
|
||||
readonly url: string
|
||||
readonly title?: string
|
||||
readonly snippet?: string
|
||||
readonly summary?: string
|
||||
readonly publishedAt?: string
|
||||
}
|
||||
```
|
||||
|
||||
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。
|
||||
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet、summary 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`snippet` 是用于展示的短摘录,`summary` 是供模型阅读的较长摘录,其长度由消费方限制。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——DeepSeek Search API 在每条结果上以可为 null 的 `date` 返回它,Exa 以 `publishedDate` 返回,Perplexity 以 `date` 返回,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。
|
||||
|
||||
Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
|
||||
DeepSeek Search API 提供方将响应 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`、`summary` ← `summary`、`publishedAt` ← `date`,空值省略。它不返回提供方生成的回答,因此 `content` 省略。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
|
||||
|
||||
完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。
|
||||
|
||||
@@ -283,7 +284,7 @@ fetch 提供方的资源控制:
|
||||
|
||||
## 测试
|
||||
|
||||
每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
|
||||
每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射,加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -293,7 +294,7 @@ fetch 提供方的资源控制:
|
||||
|
||||
### 将提供方调度直接放在 `dsh-tool-web` 中
|
||||
|
||||
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。
|
||||
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将各提供方格式差异烘焙进工具 schema 的情况下难以添加格式不同的提供方。
|
||||
|
||||
### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`)
|
||||
|
||||
@@ -322,9 +323,9 @@ fetch 提供方的资源控制:
|
||||
|
||||
## 后果
|
||||
|
||||
**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
|
||||
**搜索 schema 刻意精简。** 各搜索 API 暴露了有用的提供方特有控制,例如 Exa 的 `livecrawl`、Perplexity 的搜索时效性和 DeepSeek Search API 的 `localization`;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
|
||||
|
||||
**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
|
||||
**引用可能稀疏。** 一条 Perplexity 风格的引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
|
||||
|
||||
**稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当明确暴露结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,避免用户直到模型调用工具后才发现配置问题。
|
||||
|
||||
@@ -340,7 +341,7 @@ fetch 提供方的资源控制:
|
||||
|
||||
- `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
|
||||
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。
|
||||
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
|
||||
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待已注册的提供方都能诚实遵守时再添加。
|
||||
|
||||
## 开放问题
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: aea80ece13f2905b
|
||||
zh: a880bc1a7839cf12
|
||||
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences:
|
||||
en: 640b7ee4e8c57ed0
|
||||
zh: 1c17b3fba16861a8
|
||||
en: 41ebe22077560090
|
||||
zh: da3475e4c196c3f9
|
||||
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered:
|
||||
en: ac43499cfe7b8495
|
||||
zh: e5ab599e7d3ed753
|
||||
|
||||
@@ -58,7 +58,6 @@ The line is that these take effect with no user action, before any turn, outside
|
||||
- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
|
||||
- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
|
||||
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
|
||||
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ inherited process environment (read-only, wins)
|
||||
- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
|
||||
- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
|
||||
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。
|
||||
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 2abacff687661d28
|
||||
zh: 2d7ba3ae27dba888
|
||||
/agent-note-keep-live-configuration-in-the-owning-profile/decision:
|
||||
en: 66c3884425cfb90a
|
||||
zh: 0fe6a1f1a576470b
|
||||
en: f9b1dd3d158457a9
|
||||
zh: 06ca76fcbbc76cda
|
||||
/agent-note-keep-live-configuration-in-the-owning-profile/alternatives-considered:
|
||||
en: 4089464ad1fb60bd
|
||||
zh: 3168e9e4a205145d
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ A plugin declares every configurable value in its Cordis Config. Fields that can
|
||||
|
||||
Form writes target the active profile. Home patches and command-line overlays remain higher-priority deployment inputs; an edit they would shadow fails before persistence. Two plugin instances are addressed by their distinct profile entry ids. Nested Includes retain independent ownership and are not writable through the profile form.
|
||||
|
||||
Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a Web Search edit, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide.
|
||||
Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a legacy Messages search edit on the Web search and fetch page, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide.
|
||||
|
||||
The removed `$DSH_HOME/settings.yaml` is imported once into the active profile when the Loader has settled every entry after Settings starts: section ids are entry ids, with `ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, and `shell` → the platform's shell executor entry. The file is renamed to `settings.yaml.imported` before the first write, so the import never repeats; a rejected section is logged and stays in the renamed file.
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
表单写入当前 profile。home patch 和命令行 overlay 仍是优先级更高的部署输入;如果它们会遮蔽某次修改,编辑器会在持久化前拒绝写入。两个插件实例通过各自不同的 profile entry id 寻址。嵌套 Include 保留独立所有权,不能通过该 profile 表单写入。
|
||||
|
||||
Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,编辑网页搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。
|
||||
Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,在「网页搜索与获取」页编辑旧版 Messages 搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。
|
||||
|
||||
被移除的 `$DSH_HOME/settings.yaml` 会在 Settings 启动后、Loader 完成所有条目加载时一次性导入当前 profile:section id 即 entry id,其中 `ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目。文件在第一次写入前改名为 `settings.yaml.imported`,因此导入不会重复;被拒绝的 section 会记录日志并保留在改名后的文件中。
|
||||
|
||||
|
||||
+8
-8
@@ -6,14 +6,14 @@
|
||||
en: 9163af91ea0e030d
|
||||
zh: 92e44043b999d32f
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem:
|
||||
en: 6fac3c73a4415c83
|
||||
zh: f65b61cc1705348b
|
||||
en: 32ba2b2b42e8deb5
|
||||
zh: 6a6532561f81b8e2
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision:
|
||||
en: 458416ed7e47f644
|
||||
zh: 8b4dd820228e9dfd
|
||||
en: 08a54b37e157da29
|
||||
zh: 65f633055d9df242
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered:
|
||||
en: b731254983be656d
|
||||
zh: 5dee2b09f7faa537
|
||||
en: 6da221f6b821ea44
|
||||
zh: b15774011abed7ed
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences:
|
||||
en: 798faeaa0b406eac
|
||||
zh: 97bbfdaebc84b1d4
|
||||
en: c90a122c1f33bb83
|
||||
zh: 0175ca0f599d086b
|
||||
|
||||
+8
-8
@@ -6,17 +6,17 @@ English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed.
|
||||
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The DeepSeek web providers authenticate an operation with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. Without a readiness check, a Session on an API-key route without a key is offered a tool whose every call fails with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Fetch API of `dsh-web-deepseek-api`, which the user selects separately from the search provider ([provider selection](2026-10-29-web-provider-selection.md)), needs the same credential, so such a Session with it selected would also be offered a `web_fetch` whose every call fails.
|
||||
|
||||
## Decision
|
||||
|
||||
Each role of the web capability seam owns one part of a per-step check:
|
||||
|
||||
- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`.
|
||||
- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section.
|
||||
- **Service Provider.** Each DeepSeek provider checks its credentials in the order its operation resolves them. `dsh-web-search-deepseek` checks, on the `deepseek-account` route, whether `ctx.deepseekAccount.resolveToken()` returns a token for its Messages endpoint, and then its literal `apiKey` or its `apiKeyEnv` reference. The Search and Fetch API providers of `dsh-web-deepseek-api` resolve the same plan as the operation ([credential resolution](2026-10-29-web-provider-selection.md)): a capability with its own YAML fields checks only the key under its `*ApiKeyEnv`; otherwise, on the account route with the account model route mounted, a token for that route's `/v1/tools` base counts, and then the key of the `deepseek-official` route, or `DEEPSEEK_API_KEY` without that route. A key counts when `ctx.credentials.describe()` reports its reference configured (without that service, when the launching environment carries it); the [shared credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) perform these presence checks. A failed account-token or credential-store read answers false, because the operation fails on that read instead of trying the next credential. The providers name no tool or prompt section.
|
||||
- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set.
|
||||
|
||||
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway.
|
||||
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. A Search or Fetch API capability with its own YAML fields ignores the route and is ready exactly when its own key is configured.
|
||||
|
||||
Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none.
|
||||
|
||||
@@ -24,7 +24,7 @@ The request header records the assembled tools, so the agent loop announces each
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed.
|
||||
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as one a plugin registers, whose searches would succeed.
|
||||
|
||||
**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation.
|
||||
|
||||
@@ -36,9 +36,9 @@ The request header records the assembled tools, so the agent loop announces each
|
||||
|
||||
**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case.
|
||||
|
||||
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find.
|
||||
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Search and Fetch APIs send the chat route's own key, and the settings page saves the legacy Messages search key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so withholding them would leave a configured key unable to serve another model; a `routes` setting that opens them would be one more thing a user has to find.
|
||||
|
||||
**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint.
|
||||
**Compare the official chat route's endpoint with the web endpoint and withhold the tools when they differ.** It would keep a gateway's key away from DeepSeek, but it would also withhold web tools from users who chat through a proxy or development endpoint with a key the web endpoint accepts. The Search and Fetch APIs instead derive their endpoint from the origin of the route whose key they send, so the key and endpoint come from one route; legacy Messages search keeps its own endpoint, where a gateway user's search fails.
|
||||
|
||||
**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt.
|
||||
|
||||
@@ -46,6 +46,6 @@ The request header records the assembled tools, so the agent loop announces each
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package.
|
||||
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The HTTP fetch provider implements no `ready()`, so `web_fetch` stays admitted whenever HTTP fetch is selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key. A deployment whose official route chats through a gateway sends the gateway's key to that gateway's origin with `/v1/tools` for the Search and Fetch APIs, and to the configured Messages endpoint, the official one by default, for legacy Messages search, where each search fails unless that endpoint accepts the key. The Search and Fetch APIs read the DeepSeek model routes' Loader entries through the model provider directory.
|
||||
|
||||
Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
|
||||
Unit tests pin each role: [web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) cover each credential case of `ready()` on the account route and on other routes, including readiness that matches each operation, and [tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
|
||||
|
||||
+8
-8
@@ -6,17 +6,17 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。
|
||||
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。DeepSeek Web 提供方只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 为操作鉴权;其他路由都需要已配置的 API 密钥。没有就绪检查时,API 密钥路由上没有密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。`dsh-web-deepseek-api` 的 Fetch API 由用户与搜索提供方分别选择([提供方选择](2026-10-29-web-provider-selection.zh.md)),它需要同一份凭据,因此这样的会话选中它时,还会获得一个每次调用都失败的 `web_fetch`。
|
||||
|
||||
## Decision
|
||||
|
||||
web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。
|
||||
- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。
|
||||
- **Service Provider。** 每个 DeepSeek 提供方都按其操作解析凭据的顺序检查凭据。`dsh-web-search-deepseek` 在 `deepseek-account` 路由上检查 `ctx.deepseekAccount.resolveToken()` 是否为其 Messages 端点返回 token,然后检查其字面 `apiKey` 或 `apiKeyEnv` 引用。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方解析与操作相同的方案([凭据解析](2026-10-29-web-provider-selection.zh.md)):在 YAML 中设置了单独字段的能力只检查其 `*ApiKeyEnv` 下的密钥;否则,在账号路由上且账号模型路由已挂载时,该路由 `/v1/tools` 基础地址的 token 算数,然后是 `deepseek-official` 路由的密钥,没有该路由时为 `DEEPSEEK_API_KEY`。`ctx.credentials.describe()` 报告其引用已配置时(没有该服务时,启动环境中存在该变量),密钥即算存在;这些存在性检查由[共享凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)执行。读取账号 token 或凭据存储失败时回答 false,因为操作会在该次读取上失败,而不会尝试下一种凭据。提供方不点名任何工具或提示词段落。
|
||||
- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。
|
||||
|
||||
路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。
|
||||
路由只决定是否先检查账号 token 再检查 API 密钥,这发生在 `deepseek-account` 路由上;API 密钥在每条路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。在 YAML 中设置了单独字段的 Search 或 Fetch API 能力不看路由,恰在其自身密钥已配置时就绪。
|
||||
|
||||
只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。
|
||||
|
||||
@@ -24,7 +24,7 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。
|
||||
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如插件注册的提供方),它也会移除该工具。
|
||||
|
||||
**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。
|
||||
|
||||
@@ -36,9 +36,9 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。
|
||||
|
||||
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
|
||||
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Search 与 Fetch API 发送聊天路由自己的密钥,设置页把旧版 Messages 搜索的密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此撤下工具会让已配置的密钥无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
|
||||
|
||||
**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。
|
||||
**比较官方聊天路由的端点与 Web 端点,二者不同时撤下工具。** 这能让网关的密钥远离 DeepSeek,但也会让经代理或开发端点对话、而密钥能被 Web 端点接受的用户失去网页工具。Search 与 Fetch API 改为从其所发送密钥的路由的 origin 推导端点,因此密钥与端点来自同一条路由;旧版 Messages 搜索保留自己的端点,网关用户的搜索会在那里失败。
|
||||
|
||||
**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。
|
||||
|
||||
@@ -46,6 +46,6 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。
|
||||
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。HTTP 抓取提供方没有实现 `ready()`,因此只要选中 HTTP 抓取,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费。官方路由经网关对话的部署,对 Search 与 Fetch API 会把网关的密钥发往该网关 origin 加 `/v1/tools`,对旧版 Messages 搜索则发往配置的 Messages 端点(默认是官方端点),除非该端点接受这个密钥,否则每次搜索都会失败。Search 与 Fetch API 通过模型提供方目录读取 DeepSeek 模型路由的 Loader 条目。
|
||||
|
||||
单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
|
||||
单元测试分别固定每个角色:[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 与 [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,包括就绪判断与每次操作一致,[tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for 2026-10-29-web-provider-selection.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 .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers:
|
||||
en: 2a2fa1def8a81625
|
||||
zh: 317d8b115f34e677
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/problem:
|
||||
en: 3f5dacf86015cebb
|
||||
zh: cf0d5bceb9941554
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision:
|
||||
en: 0682ebef17f6a5e7
|
||||
zh: 6ea399459262eb0c
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/registered-providers-are-observable-availability-is-not:
|
||||
en: 38ce796dea91fa35
|
||||
zh: d312a9cd29fd5cc9
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/independent-selection:
|
||||
en: 9d68ffca5c8be681
|
||||
zh: 36bb661f98de0ebb
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution:
|
||||
en: 32f59c35d71a4832
|
||||
zh: e9288313bb18b78d
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections:
|
||||
en: 5fcd3319fbceed1d
|
||||
zh: 20771646b9c36fa6
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/legacy-messages-search:
|
||||
en: 2c7c84f8fa1e8745
|
||||
zh: 5d0244e4f17ba12a
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered:
|
||||
en: 0c227e270894cd71
|
||||
zh: 114484a3e69f5c0a
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences:
|
||||
en: 50f046bf81a7783d
|
||||
zh: 5c0e27bdcaf9f12e
|
||||
/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification:
|
||||
en: 26ade0695094e9bc
|
||||
zh: 7b265c43b1886da5
|
||||
@@ -0,0 +1,85 @@
|
||||
# Agent Note: Web provider selection - independent menus over registered providers
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-29-web-provider-selection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web service selects search and fetch providers through two independent fields, `web.searchProvider` and `web.fetchProvider`. A settings control that writes both at once ties fetch billing and target access policy to the search choice, and a control that offers only the DeepSeek choices it knows cannot select a provider that another plugin registers. A picker over registered providers needs the Host to report them, and the [web capability seam](2026-06-24-web-capability-seam.md) has no availability observation for it to reuse. Messages search starts an auxiliary model request for each query, whereas the [Search API](../feature/2026-10-15-deepseek-search-fetch-api.md) returns structured sources directly. The Search and Fetch APIs also need a DeepSeek credential, and a credential setting of their own makes every user reconcile it with the model provider they chat with.
|
||||
|
||||
## Decision
|
||||
|
||||
Users choose search and fetch providers independently from the Host's registered provider lists. The shipped composition selects the Search API for search and local HTTP fetch for fetch, and the DeepSeek Search and Fetch APIs follow the conversation's DeepSeek credential.
|
||||
|
||||
### Registered providers are observable; availability is not
|
||||
|
||||
`WebSearchProvider` and `WebFetchProvider` carry an optional `title`, the provider's display name, and an optional localized `description` of what the provider suits and what it costs or cannot do. `ctx.web.listProviders()` returns `{ search, fetch }`, each a registration-ordered list of `{ id, title?, description? }` entries, and the `web` Remote namespace exposes it as `web/listProviders`. Every registration and disposal emits the payload-free `web/providers-changed` event after the registry changes; the web service contains listener failures, so an observer cannot veto a registry change. [dsh-api-remotes](../../../../packages/api/remotes/README.md) mounts the namespace and forwards the event, and Clients re-read the list after each notification and reconnect.
|
||||
|
||||
The lists include providers whose `available()` answers false. A picker shows what can be pinned, including a provider whose credential the user has not configured yet, whereas availability depends on per-request facts such as the Session's model route and credential state. Availability therefore stays an execution result: `search()` and `fetch()` resolve the configured provider at call time and throw the structured `WebError`, as the seam note records. The tool layer still never enumerates providers.
|
||||
|
||||
### Independent selection
|
||||
|
||||
The Web search and fetch page writes `web.searchProvider` from its Search menu and `web.fetchProvider` from its Fetch menu. Each menu lists every registered provider for its capability by `title` (or `id` when `title` is absent), so plugin providers appear without page changes, and a selected id that the Host does not register stays visible as such. Official providers register no `title`; the page names them from its own locale dictionary and lists them first in a fixed order: the Search API (`deepseek-search-api`) followed by Messages search (`deepseek-official`) for search, and local HTTP fetch (`http`) followed by the Fetch API (`deepseek-fetch-api`) for fetch. Other providers follow in Host registration order. Selecting the deployment's composed provider clears the stored override instead of storing a duplicate value. The [settings page README](../../../../packages/client/ui-settings-web-search/README.md) owns the page's controls.
|
||||
|
||||
Every registered provider is listed; a provider has no flag that keeps it out of the menus. This note supersedes the [Search and Fetch API decision](../feature/2026-10-15-deepseek-search-fetch-api.md)'s rejection of listing every registered provider in a settings dropdown. Independent menus must offer providers that other plugins register, and the display metadata that listing needs is only the optional `title` and `description`. The description lives on the registration rather than in the page's dictionaries so that official and plugin providers explain themselves through one field, and a new provider needs no page change to describe its trade-offs. The rejected generic editor still stands: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages.
|
||||
|
||||
The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolves its own credential and endpoint, as the next section records. Messages search reads `web-search-deepseek` for its independent endpoint and key reference.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Defaults and stored selections
|
||||
|
||||
The [base composition](../../../../packages/bundle/base/cordis.patch.yml) sets `searchProvider: deepseek-search-api` and `fetchProvider: http`. Search defaults to the Search API because it returns sources without Harness starting an auxiliary model request. Fetch defaults to local HTTP fetch because it has no per-request API charge, applies Harness's own URL validation and resource limits, and reaches intranet and local addresses. The Fetch API remains one menu choice away.
|
||||
|
||||
A stored selection, whether in the settings document or in a deployment's own composition, wins over the shipped default, and an upgrade does not rewrite it. A selection is never replaced at execution: when the selected provider is missing, unavailable, or fails, the operation fails with its structured error and does not fall back to another search or fetch provider. A fallback would send the query and credential to an endpoint the user did not choose and change billing without notice.
|
||||
|
||||
### Legacy Messages search
|
||||
|
||||
The DeepSeek Messages search provider remains available as **DeepSeek (legacy)**. A stored `web.searchProvider: deepseek-official` (or `DSH_WEB_SEARCH_PROVIDER=deepseek-official`) selects it, so users whose gateways serve Messages search but not `/v1/tools` can retain their working provider.
|
||||
|
||||
The Messages provider owns and writes `web/deepseek-search-llm-request`. The declaration and payload retain compatibility with released Sessions; the Search and Fetch API package neither declares nor writes this auxiliary event.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep one switch for both APIs.** It lets Fetch API adoption need no configuration edit, but it ties fetch billing and target access policy to the search choice, and it cannot express a provider that another plugin registers.
|
||||
|
||||
**Filter the lists by availability, or add availability to each entry.** A filtered list hides a provider while its credential is missing, right when the user needs to choose and configure it. Availability also depends on the request's model route and credential state, so a listed boolean could disagree with execution. The archived [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) allows only the smallest signal a consumer reads; the menus read registration only.
|
||||
|
||||
**Keep a fixed provider catalog in the Client.** The Client would show ids the Host does not register as normal choices and would never show a provider that a plugin registers at runtime.
|
||||
|
||||
**Carry the provider list in the change event.** A payload-free event leaves one read path for initial load, notifications, and reconnects, so the Client never has to order an event payload against a concurrent read.
|
||||
|
||||
**Keep display names in the Client or in a separate Host registry.** The registering plugin knows its provider's name; a separate map would let a provider and its label register or dispose independently.
|
||||
|
||||
**Fall back to another provider when the selected one fails.** A fallback hides gateway and credential misconfiguration, sends the query to a second service, and changes what the request costs without the user choosing it.
|
||||
|
||||
**Remove Messages search and let a stored `deepseek-official` select the Search API.** Gateways can support Messages search without Search API routes. Retaining the legacy provider preserves that choice instead of changing its protocol and billing.
|
||||
|
||||
**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.
|
||||
|
||||
**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways.
|
||||
|
||||
**Fail an account conversation without a sign-in instead of sending the API key.** It would keep the account route from ever billing the API key, but signed-out account conversations would lose web tools even when a DeepSeek API key is configured.
|
||||
|
||||
**Send the account token whenever the user is signed in, whatever the conversation's route.** It would bill web access to the account during conversations on the API-key route or on another provider, which the user did not choose for them.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Agent Note: Web 提供方选择——基于已注册提供方的独立下拉框
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-29-web-provider-selection.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 两个独立字段分别选择搜索与抓取提供方。用一个设置控件同时写入两者,会把抓取的计费和目标访问策略绑在搜索选择上;只提供已知 DeepSeek 选项的控件,也无法选用其他插件注册的提供方。基于已注册提供方的选择器需要 Host 报告这些提供方,而 [Web 能力 seam](2026-06-24-web-capability-seam.zh.md) 没有可供复用的可用性观察接口。Messages 搜索每次查询都会发起一次辅助模型请求,而 [Search API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md) 能直接返回结构化来源。Search 与 Fetch API 还需要 DeepSeek 凭据;为它们单独设置凭据,会让每个用户都要把它与自己对话所用的模型提供方对齐。
|
||||
|
||||
## 决策
|
||||
|
||||
用户从 Host 已注册的提供方列表中分别选择搜索和抓取提供方。内置组合将搜索设为 Search API,将抓取设为本地 HTTP 抓取;DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据。
|
||||
|
||||
### 已注册的提供方可观察,可用性不可观察
|
||||
|
||||
`WebSearchProvider` 和 `WebFetchProvider` 带有可选的 `title`(提供方的显示名称)和可选的多语言 `description`(说明该提供方适合什么、有什么代价或做不到什么)。`ctx.web.listProviders()` 返回 `{ search, fetch }`,二者均为按注册顺序排列的 `{ id, title?, description? }` 列表,`web` Remote namespace 将其暴露为 `web/listProviders`。每次注册和注销都会在注册表变更后触发无载荷的 `web/providers-changed` 事件;Web 服务会隔离监听器异常,因此观察者无法阻断注册表变更。[dsh-api-remotes](../../../../packages/api/remotes/README.zh.md) 挂载该 namespace 并转发该事件,Client 在收到通知和重连后重新读取列表。
|
||||
|
||||
列表包含 `available()` 返回 false 的提供方。选择器展示的是可固定选用的项,包括用户尚未配置凭据的提供方;而可用性取决于会话的模型路由、凭据状态等单次请求条件。因此可用性仍是执行期结果:`search()` 和 `fetch()` 在调用时解析所配置的提供方并抛出结构化 `WebError`,与 seam 记录一致。工具层仍从不枚举提供方。
|
||||
|
||||
### 分别选择
|
||||
|
||||
「网页搜索与获取」页由 Search 下拉框写入 `web.searchProvider`,由 Fetch 下拉框写入 `web.fetchProvider`。每个下拉框以 `title`(缺省时用 `id`)列出该能力的所有已注册提供方,因此插件提供方无需修改页面即可出现,且 Host 未注册的已选 id 仍会照常显示。官方提供方不注册 `title`,由页面用自己的语言字典命名,并按固定顺序排在最前:搜索依次为 Search API(`deepseek-search-api`)与 Messages 搜索(`deepseek-official`),抓取依次为本地 HTTP 抓取(`http`)和 Fetch API(`deepseek-fetch-api`)。其他提供方按 Host 注册顺序排列。选择部署组合的默认提供方会清除已保存的覆盖值,而非冗余保存一份副本。[设置页 README](../../../../packages/client/ui-settings-web-search/README.zh.md) 负责页面控件说明。
|
||||
|
||||
所有已注册的提供方都会列出;提供方没有可以把自己排除在下拉框之外的标志。本 Note 取代了 [Search 与 Fetch API 决策](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)对「在设置页用下拉框列出所有已注册的提供方」的否决。独立的下拉框必须提供其他插件注册的提供方,而列出提供方所需的展示元数据只是可选的 `title` 和 `description`。说明放在注册信息里而不是页面词典里,这样官方与插件提供方都通过同一个字段说明自己,新提供方无需修改页面即可说明其取舍。被否决的通用编辑表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。
|
||||
|
||||
Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解析自己的凭据和端点,见下一节。Messages 搜索从 `web-search-deepseek` 读取独立的端点和密钥引用。
|
||||
|
||||
### 凭据解析
|
||||
|
||||
Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `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` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。
|
||||
|
||||
在 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 密钥路径就能工作。
|
||||
|
||||
### 默认值与已保存的选择
|
||||
|
||||
[base 组合](../../../../packages/bundle/base/cordis.patch.yml)设置 `searchProvider: deepseek-search-api` 和 `fetchProvider: http`。搜索默认使用 Search API,因为它返回来源时无需 Harness 发起辅助模型请求。抓取默认使用本地 HTTP 抓取,因为它没有按次的 API 费用,执行 Harness 自身的 URL 校验和资源限制,并且可以访问内网和本地地址。Fetch API 仍可在下拉框中直接选择。
|
||||
|
||||
已保存的选择——无论位于设置文档还是部署自身的组合中——优先于内置默认值,升级不会改写它。执行时从不替换选择:所选提供方缺失、不可用或失败时,操作以结构化错误失败,不会回退到其他搜索或抓取提供方。回退会把查询和凭据发往用户未选择的端点,并在不提示的情况下改变计费。
|
||||
|
||||
### 旧版 Messages 搜索
|
||||
|
||||
DeepSeek Messages 搜索提供方仍以 **DeepSeek(旧版)**提供。已保存的 `web.searchProvider: deepseek-official`(或 `DSH_WEB_SEARCH_PROVIDER=deepseek-official`)会选中它,因此网关支持 Messages 搜索但不提供 `/v1/tools` 的用户可以保留可用的提供方。
|
||||
|
||||
Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声明和载荷保持与已发布会话兼容;Search 与 Fetch API 包既不声明也不写入这一辅助事件。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留一个同时切换两个 API 的开关。** 它让使用 Fetch API 无需修改配置,但把抓取的计费和目标访问策略绑在搜索选择上,也无法表达其他插件注册的提供方。
|
||||
|
||||
**按可用性过滤列表,或在每个条目中加入可用性。** 过滤后的列表恰好会在凭据缺失时隐藏提供方,而这正是用户需要选择并配置它的时候。可用性还取决于请求的模型路由和凭据状态,列表中的布尔值可能与实际执行结果不一致。已归档的[观察面决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)只允许引入消费方实际读取的最小信号;下拉框只读取注册情况。
|
||||
|
||||
**在 Client 中维护固定的提供方目录。** Client 会把 Host 未注册的 id 当作正常选项展示,且永远无法展示插件在运行时注册的提供方。
|
||||
|
||||
**在变更事件中携带提供方列表。** 无载荷事件让初次加载、变更通知和断线重连共用同一条读取路径,Client 无需为事件载荷与并发读取结果排序。
|
||||
|
||||
**把显示名称放在 Client 或单独的 Host 注册表中。** 注册插件最清楚自身提供方的名称;拆成独立映射会让提供方与其标签各自独立注册或注销。
|
||||
|
||||
**所选提供方失败时回退到其他提供方。** 回退会掩盖网关和凭据的配置错误,将查询发往第二个服务,并在未经用户选择的情况下改变请求的费用。
|
||||
|
||||
**移除 Messages 搜索,让已保存的 `deepseek-official` 选中 Search API。** 网关可能支持 Messages 搜索,却没有 Search API 路由。保留旧版提供方可以保留这一选择,而不改变其协议和计费。
|
||||
|
||||
**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。
|
||||
|
||||
**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。
|
||||
|
||||
**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。
|
||||
|
||||
**账号会话未登录时直接失败,而不是发送 API 密钥。** 这样账号路由永远不会消耗 API 密钥,但即使配置了 DeepSeek API 密钥,未登录的账号会话也会失去网页工具。
|
||||
|
||||
**只要用户已登录就发送账号令牌,不论会话的路由。** 这会让在 API 密钥路由或其他提供方上进行的会话把 Web 访问记到账号上,而用户并没有为这些会话这样选择。
|
||||
|
||||
## 影响
|
||||
|
||||
没有已保存搜索选择的用户通过 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)列出了每种受影响的配置。
|
||||
|
||||
插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `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 不显示凭据控件,以及各提供方保留的设置。
|
||||
@@ -9,11 +9,11 @@
|
||||
en: d1aad580a4db9872
|
||||
zh: 1eb3bac59353291d
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision:
|
||||
en: e70efbb3aa9d53b6
|
||||
zh: 1d25eae2461346df
|
||||
en: 43298ccae4f7a6d4
|
||||
zh: eb7ef46b6f290c15
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment:
|
||||
en: ec653b59d62ed95f
|
||||
zh: 0d675616059930ba
|
||||
en: 90e5031d0d2fa9e4
|
||||
zh: a9c0229b20555d9e
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
|
||||
en: ad7b3e4fbe528cf2
|
||||
zh: 8ab8779bfdaaa4fa
|
||||
@@ -21,17 +21,17 @@
|
||||
en: 7566e400f9ab3a51
|
||||
zh: 118b2e84d0030480
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
|
||||
en: d5a8e36b545f1c04
|
||||
zh: d741fd0789401c62
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix:
|
||||
en: 738e32b488bbc1a1
|
||||
zh: f33afe570f62599a
|
||||
en: cb2f0910955b6730
|
||||
zh: 7df057048acd7858
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/decision/legacy-messages-search:
|
||||
en: 0dcc97d8105fab60
|
||||
zh: d80d40ae3a0c601d
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered:
|
||||
en: 641cbfefc76f2f95
|
||||
zh: e3e718473eace85f
|
||||
en: 907e0b8424943d43
|
||||
zh: f45e583e7cb4eb63
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/consequences:
|
||||
en: 124a35cad810c2fc
|
||||
zh: 504da2a59c3873be
|
||||
en: 7ec21c5b1124e5f8
|
||||
zh: af769dbdb4face9f
|
||||
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
|
||||
en: 4c84e7bd035b52a7
|
||||
zh: 4e5687bda0a47706
|
||||
en: 595a35ce727a7c82
|
||||
zh: 10b2197663198015
|
||||
|
||||
@@ -10,15 +10,15 @@ DeepSeek web retrieval through Messages requires an auxiliary model request for
|
||||
|
||||
## Decision
|
||||
|
||||
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields.
|
||||
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields, and the [web provider selection decision](../architecture/2026-10-29-web-provider-selection.md) owns how users choose each provider and which providers the shipped composition selects.
|
||||
|
||||
### Protocol selection and deployment
|
||||
|
||||
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together.
|
||||
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`. Messages search remains separately registered as `deepseek-official`. The web service selects each provider through `web.searchProvider` and `web.fetchProvider`, which the Search and Fetch menus on the Web search and fetch page set independently; the shipped composition selects the Search API and local HTTP fetch, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. A saved selection takes effect on the next operation because the web service reads both selections per operation. Each operation resolves one settings snapshot, including its endpoint and credential. Both APIs read the `web-deepseek-api` namespace, and each capability resolves its own endpoint and credential.
|
||||
|
||||
The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable.
|
||||
Direct retrieval appends `/search` or `/fetch` to an API base. A capability without its own YAML endpoint fields uses the origin of the conversation's DeepSeek model route base URL with `/v1/tools`, which for the public deployment is the guide's `https://api.deepseek.com/v1/tools`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that derivation and the credential each operation sends. The verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`; its prefix differs and is never inferred from the hostname, so such a deployment sets the complete base in `searchBaseURL` or `fetchBaseURL`. An unparseable base leaves that capability's provider unavailable.
|
||||
|
||||
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search.
|
||||
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A conversation on the `deepseek-account` route sends the account token instead; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users have direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments can select Messages search or set the capability's own base URL to one that serves `/v1/tools`.
|
||||
|
||||
### Search results and model input
|
||||
|
||||
@@ -36,45 +36,41 @@ The HTTP provider retains its own URL validation and resource limits. Selecting
|
||||
|
||||
### Default migration
|
||||
|
||||
Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages.
|
||||
The shipped composition selects `deepseek-search-api`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that default and its no-fallback rule. API keys and account tokens both reach the public routes. Deployments behind a gateway without `/v1/tools` routes set `searchBaseURL` to a base that serves them. A stored `web.searchProvider: deepseek-official` selects Messages search, and any other stored selection keeps its provider.
|
||||
|
||||
### Supplementary Messages fix
|
||||
### Legacy Messages search
|
||||
|
||||
The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field.
|
||||
The independent `dsh-web-search-deepseek` provider sends Messages search requests when `deepseek-official` is selected, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. Its `web/deepseek-search-llm-request` Session event type, including the optional `thinking: { type: 'disabled' }` body field that the [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) covers, stays declared and written in `dsh-web-search-deepseek`, so released Sessions that contain the event still open. Messages search sends that field because Harness discards the generated answer and the auxiliary request needs no reasoning.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode.
|
||||
|
||||
**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search.
|
||||
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request.
|
||||
|
||||
**Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route.
|
||||
|
||||
**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it.
|
||||
**Require an explicit Search API base.** The Search API is the shipped search default, and an empty default would leave it unavailable until every user enters an address that the DeepSeek model route already determines. Other deployments still set their own base.
|
||||
|
||||
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone.
|
||||
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside the Messages provider cannot change fetch routing, so `web_fetch` would stay on HTTP unless the user also edited `web.fetchProvider`; it also ties the APIs' lifecycle to Messages search. Separate registrations let the web service's existing selection choose each provider, without coupling either API to the Messages provider.
|
||||
|
||||
**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then.
|
||||
**Share one transport between Messages search and the new APIs.** A shared web transport would couple different request formats and failure mappings. Credential values instead come from the shared [`llm-deepseek` credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts); Messages search and the Search/Fetch API transports remain independent.
|
||||
|
||||
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other.
|
||||
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential; separate packages would duplicate settings that address one deployment. Independent selection does not require separate packages, because each API registers its own provider id. Each operation lives in its own module, so either API can change without the other.
|
||||
|
||||
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages.
|
||||
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. This rejection stands for the generic form: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. The [selection decision](../architecture/2026-10-29-web-provider-selection.md) supersedes it for the provider menus, which list every registered provider by its `title`.
|
||||
|
||||
**Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase.
|
||||
|
||||
**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone.
|
||||
|
||||
**Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain.
|
||||
|
||||
## Consequences
|
||||
|
||||
Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
|
||||
Direct retrieval requires no auxiliary model tokens, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
|
||||
|
||||
Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The tool accepts up to `searchMaxQueries` queries per call, 10 by default, and fails the batch if one query fails. The Search provider sends at most `searchRequestsPerSecond` requests, 5 by default, in any one-second window of one plugin instance, so the second five queries of a full batch start one second after the first. The pacing is not shared across processes, and the provider has no automatic retries or fallback; other processes or clients can exhaust the same account allowance.
|
||||
|
||||
## Verification and remaining API adoption
|
||||
|
||||
Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
|
||||
Provider tests cover request mapping, empty results, malformed responses, independent and derived bases, account-token authentication and its invalidation after HTTP 401, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the result count, and independent Search and Fetch selection. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
|
||||
|
||||
The following capabilities remain outside the implemented integration:
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。
|
||||
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段;[Web 提供方选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责用户如何选择各个提供方,以及内置组合选择哪些提供方。
|
||||
|
||||
### 协议选择与部署
|
||||
|
||||
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。
|
||||
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索仍独立注册为 `deepseek-official`。Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 选择各个提供方,「网页搜索与获取」页上的 Search 和 Fetch 下拉框分别设置这两项;内置组合选择 Search API 与本地 HTTP 抓取,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。已保存的选择从下一次操作起生效,因为 Web 服务在每次操作时读取两项选择。每次操作解析一份包含端点和凭据的设置快照。两个 API 读取 `web-deepseek-api` namespace,每个能力各自解析端点与凭据。
|
||||
|
||||
两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。
|
||||
直接检索在 API 基础地址后追加 `/search` 或 `/fetch`。没有在 YAML 中单独设置端点字段的能力,使用会话的 DeepSeek 模型路由基础地址的 origin 加 `/v1/tools`,公网部署即指南中的 `https://api.deepseek.com/v1/tools`;该推导及每次操作发送的凭据由[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责。已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`;其前缀不同,程序不会根据主机名推断,此类部署需在 `searchBaseURL` 或 `fetchBaseURL` 中填写完整基础地址。基础地址无法解析时,该能力的提供方不可用。
|
||||
|
||||
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。
|
||||
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。`deepseek-account` 路由上的会话改为发送账号 token;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也能使用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署可以选择 Messages 搜索,或把该能力的单独基础地址设为提供 `/v1/tools` 的地址。
|
||||
|
||||
### 搜索结果与模型输入
|
||||
|
||||
@@ -36,45 +36,41 @@ HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek
|
||||
|
||||
### 默认值迁移
|
||||
|
||||
将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。
|
||||
内置组合选择 `deepseek-search-api`;[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责这项默认值及其不回退规则。API 密钥和账号 token 都能访问公网路由。位于没有 `/v1/tools` 路由的网关之后的部署,需把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `web.searchProvider: deepseek-official` 会选中 Messages 搜索,其他已保存的选择保持其提供方。
|
||||
|
||||
### 附带的 Messages 修复
|
||||
### 旧版 Messages 搜索
|
||||
|
||||
保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。
|
||||
选中 `deepseek-official` 时,独立的 `dsh-web-search-deepseek` 提供方发送 Messages 搜索请求,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。它的 `web/deepseek-search-llm-request` 会话事件类型(包括[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)覆盖的可选请求体字段 `thinking: { type: 'disabled' }`)仍由 `dsh-web-search-deepseek` 声明并写入,因此包含该事件的已发布会话仍能打开。Messages 搜索发送该字段,是因为 Harness 丢弃生成的回答,辅助请求无需推理。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。
|
||||
|
||||
**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。
|
||||
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求。
|
||||
|
||||
**复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。
|
||||
|
||||
**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。
|
||||
**要求显式填写 Search API 基础地址。** Search API 是内置的搜索默认值,空默认值会让它在每位用户填写一个 DeepSeek 模型路由已经确定的地址之前都不可用。其他部署仍可设置自己的基础地址。
|
||||
|
||||
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。
|
||||
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** Messages 提供方内的开关无法改变抓取路由,因此除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍会走 HTTP;它还会把两个 API 的生命周期绑在 Messages 搜索上。独立注册让 Web 服务通过现有的提供方选择机制选择各个提供方,不会把任一 API 与 Messages 提供方绑定。
|
||||
|
||||
**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。
|
||||
**让 Messages 搜索与新 API 共用一套传输层。** 共享 Web 传输层会耦合不同的请求格式和失败映射。凭据值改为通过共享的 [`llm-deepseek` 凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)获取;Messages 搜索与 Search/Fetch API 传输层保持独立。
|
||||
|
||||
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。
|
||||
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API;拆成两个包会让指向同一部署的设置重复出现。分别选择不需要拆包,因为每个 API 注册自己的提供方 id。每种操作位于独立模块,任一 API 都可以单独修改。
|
||||
|
||||
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。
|
||||
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。这一否决对通用表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。对于提供方下拉框,[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)取代了这一否决,下拉框按 `title` 列出所有已注册的提供方。
|
||||
|
||||
**让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。
|
||||
|
||||
**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。
|
||||
|
||||
**新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。
|
||||
|
||||
## 影响
|
||||
|
||||
直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
|
||||
直接检索不需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
|
||||
|
||||
Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。工具每次调用最多接受 `searchMaxQueries` 个查询,默认 10 个;任一查询失败会使整批失败。Search 提供方在单个插件实例的任意一秒窗口内最多发送 `searchRequestsPerSecond` 个请求,默认 5 个,因此满批次的后五个查询比前五个晚一秒开始。该限速不跨进程共享,提供方也没有自动重试或回退;其他进程或客户端也会消耗同一账号的额度。
|
||||
|
||||
## 验证与尚未接入的 API 能力
|
||||
|
||||
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
|
||||
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与推导地址、账号 token 鉴权及其在 HTTP 401 后被作废、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、结果数,以及分别选择 Search 与 Fetch。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
|
||||
|
||||
以下能力尚未包含在已实现的接入中:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
/dsh-cli-behavior-reference:
|
||||
en: 5a4f43756dae900e
|
||||
en: abffba1bcc44698c
|
||||
zh: d58c479481cfa5fe
|
||||
/dsh-cli-behavior-reference/profile-boot:
|
||||
en: b474a6fc3c98837d
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 149c5571acd51baf
|
||||
zh: 300e3c2425273a92
|
||||
/dsh-cli-behavior-reference/shared-deployment-behavior:
|
||||
en: f40edcf903a51f3f
|
||||
zh: 8a9a915451047c8b
|
||||
en: f02219118dcec531
|
||||
zh: fe2977628d7340da
|
||||
/dsh-cli-behavior-reference/source-execution:
|
||||
en: 0fd6c72ba9a7eb3b
|
||||
zh: 011c0ac4008ee480
|
||||
|
||||
@@ -6,6 +6,8 @@ This reference defines the profile, plugin-management, and config-dump command m
|
||||
|
||||
The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI.
|
||||
|
||||
<a id="profile-boot"></a>
|
||||
|
||||
## Profile boot
|
||||
|
||||
`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
|
||||
@@ -134,7 +136,7 @@ Web, headless, and SDK inherit native tool execution from the base bundle; use a
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. The DeepSeek Search and Fetch APIs follow the session's DeepSeek credentials; custom endpoints and keys can only be set in YAML through `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` and `fetchBaseURL` / `fetchApiKeyEnv` ([authentication](../../../packages/web/web-deepseek-api/README.md#authentication)). Legacy Messages search reads `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
|
||||
|
||||
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据;自定义 endpoint 和密钥只能在 YAML 中通过 `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` 和 `fetchBaseURL` / `fetchApiKeyEnv` 设置([鉴权](../../../packages/web/web-deepseek-api/README.zh.md#authentication))。旧版 Messages 搜索读取 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
|
||||
|
||||
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
|
||||
|
||||
|
||||
@@ -42,8 +42,14 @@
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
# A search key reference no runner sets: the turn runs as a deployment without
|
||||
# web search credentials, whatever the launching environment exports.
|
||||
# Own key references no runner sets on the DeepSeek Search and Fetch APIs: the
|
||||
# turn runs as a deployment without web search credentials, whatever
|
||||
# DEEPSEEK_API_KEY the launching environment exports.
|
||||
- id: web-deepseek-api
|
||||
config:
|
||||
searchApiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
|
||||
fetchApiKeyEnv: DSH_KEYLESS_SMOKE_FETCH_KEY
|
||||
|
||||
- id: web-search-deepseek
|
||||
config:
|
||||
apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('headless-agent keyless smoke', () => {
|
||||
expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP')
|
||||
expect(persistedHeader).toMatchObject({ type: 'session' })
|
||||
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch']))
|
||||
// Search has no key in this composition, so the model request does not offer it.
|
||||
// Neither DeepSeek search provider has a key in this composition, so the model request does not offer search.
|
||||
expect(persistedToolNames).not.toContain('web_search')
|
||||
expect(persistedToolNames).not.toContain('str_replace_editor')
|
||||
}, PRODUCTION_PROFILE_TEST_TIMEOUT_MS)
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- button "查看 子智能体": 子智能体
|
||||
- text: 设置子智能体的递归层级、数量和模型。
|
||||
- listitem:
|
||||
- button "查看 网页搜索": 网页搜索
|
||||
- text: 设置 DeepSeek 的搜索提供方。
|
||||
- button "查看 网页搜索与获取": 网页搜索与获取
|
||||
- text: 选择网页搜索与网页获取使用的提供方。
|
||||
- listitem:
|
||||
- button "查看 Agent 循环": Agent 循环
|
||||
- text: 控制 Agent 派发工具调用的方式。
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
- button "返回插件": 插件
|
||||
- heading "网页搜索" [level=3]
|
||||
- paragraph: 设置 DeepSeek 的搜索提供方。
|
||||
- text: 启用新版 Web 搜索 API
|
||||
- switch "启用新版 Web 搜索 API" [checked]
|
||||
- paragraph:
|
||||
- text: 默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见
|
||||
- link "网页搜索与抓取指南":
|
||||
- /url: https://api-docs.deepseek.com/zh-cn/guides/search
|
||||
- text: API Key 未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。
|
||||
- textbox "API Key"
|
||||
- paragraph: 不写入设置文件。留空表示保持当前密钥。
|
||||
- text: Search / Fetch API 基础地址 已覆盖
|
||||
- button "恢复默认"
|
||||
- textbox "Search / Fetch API 基础地址":
|
||||
- /placeholder: ""
|
||||
- text: https://search-api.test/api/v1/tools
|
||||
- paragraph: 填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。
|
||||
- text: 每次搜索结果数
|
||||
- textbox "每次搜索结果数":
|
||||
- /placeholder: ""
|
||||
- text: "10"
|
||||
- paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。
|
||||
- heading "网页搜索与获取" [level=3]
|
||||
- paragraph: 选择网页搜索与网页获取使用的提供方。
|
||||
- region "网页搜索提供方":
|
||||
- heading "网页搜索提供方" [level=4]
|
||||
- button "网页搜索提供方 DeepSeek Search API": DeepSeek Search API
|
||||
- paragraph: 直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费
|
||||
- paragraph:
|
||||
- link "网页搜索与获取指南":
|
||||
- /url: https://api-docs.deepseek.com/zh-cn/guides/search
|
||||
- text: 每次搜索结果数
|
||||
- textbox "每次搜索结果数":
|
||||
- /placeholder: ""
|
||||
- text: "10"
|
||||
- paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。
|
||||
- region "网页获取提供方":
|
||||
- heading "网页获取提供方" [level=4]
|
||||
- button "网页获取提供方 DeepSeek Fetch API": DeepSeek Fetch API
|
||||
- paragraph: 由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机
|
||||
- button "保存" [disabled]
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- button "查看 子智能体": 子智能体
|
||||
- text: 设置子智能体的递归层级、数量和模型。
|
||||
- listitem:
|
||||
- button "查看 网页搜索": 网页搜索
|
||||
- text: 设置 DeepSeek 的搜索提供方。
|
||||
- button "查看 网页搜索与获取": 网页搜索与获取
|
||||
- text: 选择网页搜索与网页获取使用的提供方。
|
||||
- listitem:
|
||||
- button "查看 Agent 循环": Agent 循环
|
||||
- text: 控制 Agent 派发工具调用的方式。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- button "查看 子智能体": 子智能体
|
||||
- text: 设置子智能体的递归层级、数量和模型。
|
||||
- listitem:
|
||||
- button "查看 网页搜索": 网页搜索
|
||||
- text: 设置 DeepSeek 的搜索提供方。
|
||||
- button "查看 网页搜索与获取": 网页搜索与获取
|
||||
- text: 选择网页搜索与网页获取使用的提供方。
|
||||
- listitem:
|
||||
- button "查看 Agent 循环": Agent 循环
|
||||
- text: 控制 Agent 派发工具调用的方式。
|
||||
|
||||
@@ -35,10 +35,11 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
beforeAll(async () => {
|
||||
// The live-client fixture is a bundle with a browser half; switched on
|
||||
// below, that half registers its row's configuration into the page.
|
||||
// Legacy Messages search names an unset credential so the goldens show the
|
||||
// unconfigured key whatever keys the host environment carries.
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
deepSeekSearch: { apiKeyEnv: UNUSED_KEY_ENV },
|
||||
deepSeekWebApi: { apiKeyEnv: UNUSED_KEY_ENV },
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
@@ -92,12 +93,12 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
|
||||
// Every page the shipped web composition exposes: the shell executor, the
|
||||
// agent loop, subagent selection, and the DeepSeek search provider.
|
||||
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
|
||||
await panel.getByRole('button', { name: '查看 网页搜索与获取', exact: true }).waitFor({ timeout: 20_000 })
|
||||
const basic = panel.locator('[data-plugin-group="basic"]')
|
||||
expect(await basic.locator('[data-plugin-item]').count()).toBe(4)
|
||||
expect(await basic.locator(':scope > ul > li').evaluateAll(cards => cards.map(card => card.getAttribute('data-plugin-item'))))
|
||||
.toEqual(['subagent', 'web-search', 'agent-loop', 'shell'])
|
||||
for (const title of ['子智能体', '网页搜索', 'Agent 循环', '终端']) {
|
||||
for (const title of ['子智能体', '网页搜索与获取', 'Agent 循环', '终端']) {
|
||||
expect(await basic.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
|
||||
}
|
||||
// A card carries the one-liner; the fields wait for the page.
|
||||
@@ -142,63 +143,107 @@ describe('web e2e: plugin configuration pages', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('switches search and fetch to the Search and Fetch APIs together and preserves each API’s endpoint', async () => {
|
||||
it('selects search and fetch providers independently and keeps each provider’s settings', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-search-api-settings'))
|
||||
const panel = await openPlugins()
|
||||
await openPage(panel, '网页搜索')
|
||||
const toggle = panel.getByRole('switch', { name: '启用新版 Web 搜索 API', exact: true })
|
||||
const endpoint = panel.locator('#plugin-config-web-search-endpoint')
|
||||
const maxUses = panel.getByLabel('单次请求最多搜索次数', { exact: true })
|
||||
const save = panel.getByRole('button', { name: '保存', exact: true })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(await panel.getByRole('link', { name: '网页搜索与抓取指南', exact: true }).getAttribute('href'))
|
||||
.toBe('https://api-docs.deepseek.com/zh-cn/guides/search')
|
||||
expect(await panel.getByLabel('Messages API 基础地址', { exact: true }).count()).toBe(1)
|
||||
expect(await maxUses.inputValue()).toBe('5')
|
||||
expect(await endpoint.inputValue()).toBe('')
|
||||
expect(await save.isDisabled()).toBe(true)
|
||||
await endpoint.fill('https://messages.test/anthropic/v1')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Space')
|
||||
expect(await panel.getByLabel('Search / Fetch API 基础地址', { exact: true }).count()).toBe(1)
|
||||
expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools')
|
||||
expect(await maxUses.count()).toBe(0)
|
||||
await openPage(panel, '网页搜索与获取')
|
||||
// A menu's accessible name is its label followed by the selected entry, so the menus are found by id.
|
||||
const searchMenu = panel.locator('#plugin-config-web-search-provider')
|
||||
const fetchMenu = panel.locator('#plugin-config-web-fetch-provider')
|
||||
const searchSection = panel.getByRole('region', { name: '网页搜索提供方', exact: true })
|
||||
const fetchSection = panel.getByRole('region', { name: '网页获取提供方', exact: true })
|
||||
const count = panel.getByLabel('每次搜索结果数', { exact: true })
|
||||
const save = panel.getByRole('button', { name: '保存', exact: true })
|
||||
/** Pick one entry from a menu. */
|
||||
async function choose(menu: Locator, label: string): Promise<void> {
|
||||
await menu.click()
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click()
|
||||
}
|
||||
/** Open a menu and read its entries in order, then close it. */
|
||||
async function entries(menu: Locator): Promise<string[]> {
|
||||
await menu.click()
|
||||
const items = await page.getByRole('menuitem').allTextContents()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => page.getByRole('menuitem').count()).toBe(0)
|
||||
return items
|
||||
}
|
||||
// The shipped composition selects the DeepSeek Search API and local fetch, and the page writes nothing on mount.
|
||||
expect(await searchMenu.textContent()).toBe('DeepSeek Search API')
|
||||
expect(await fetchMenu.textContent()).toBe('本机直接获取')
|
||||
expect(await panel.getByRole('button', { name: '网页搜索提供方 DeepSeek Search API', exact: true }).count()).toBe(1)
|
||||
expect(await panel.getByRole('button', { name: '网页获取提供方 本机直接获取', exact: true }).count()).toBe(1)
|
||||
expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)'])
|
||||
expect(await entries(fetchMenu)).toEqual(['本机直接获取', 'DeepSeek Fetch API'])
|
||||
expect(await settingsDocument()).not.toContain('searchProvider:')
|
||||
// The page fixes the menu order itself; the Loader imports plugins concurrently, so the
|
||||
// Host's registration order is not the YAML row order and only its membership is checked here.
|
||||
const hostWeb = scaffold.ctx.get('web') as { listProviders(): Record<'search' | 'fetch', { id: string }[]> } | undefined
|
||||
expect(hostWeb?.listProviders().search.map(provider => provider.id).sort()).toEqual(['deepseek-official', 'deepseek-search-api'])
|
||||
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)
|
||||
// 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.
|
||||
expect(await searchSection.getByRole('textbox').count()).toBe(1)
|
||||
expect(await count.inputValue()).toBe('10')
|
||||
await count.fill('51')
|
||||
await panel.getByText('请填 1–50 的整数;留空表示使用默认值。', { exact: true }).waitFor()
|
||||
expect(await save.isDisabled()).toBe(true)
|
||||
expect(await toggle.isDisabled()).toBe(true)
|
||||
for (const menu of [searchMenu, fetchMenu]) expect(await menu.isDisabled()).toBe(true)
|
||||
await count.fill('10')
|
||||
expect(await settingsDocument()).not.toContain('searchProvider: deepseek-search-api')
|
||||
await endpoint.fill('https://search-api.test/api/v1/tools')
|
||||
expect(await save.isDisabled()).toBe(true)
|
||||
|
||||
// The Fetch API takes no settings here, so its section shows no panel.
|
||||
await choose(fetchMenu, 'DeepSeek Fetch API')
|
||||
expect(await fetchSection.getByText('由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', { exact: true }).count()).toBe(1)
|
||||
expect(await fetchSection.getByRole('textbox').count()).toBe(0)
|
||||
expect(await fetchSection.getByRole('button').count()).toBe(1)
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(settingsDocument).toContain('searchProvider: deepseek-search-api')
|
||||
await expect.poll(settingsDocument).toContain('fetchProvider: deepseek-fetch-api')
|
||||
await expect.poll(settingsDocument).toContain('baseURL: https://search-api.test/api/v1/tools')
|
||||
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openPlugins()
|
||||
await openPage(panel, '网页搜索')
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await endpoint.inputValue()).toBe('https://search-api.test/api/v1/tools')
|
||||
await openPage(panel, '网页搜索与获取')
|
||||
expect(await searchMenu.textContent()).toBe('DeepSeek Search API')
|
||||
expect(await fetchMenu.textContent()).toBe('DeepSeek Fetch API')
|
||||
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'web-search.expected.md'), snapshot, MODE)
|
||||
await panel.getByRole('button', { name: '恢复默认', exact: true }).click()
|
||||
expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools')
|
||||
|
||||
await choose(fetchMenu, '本机直接获取')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(settingsDocument).not.toContain('https://search-api.test/api/v1/tools')
|
||||
await toggle.click()
|
||||
expect(await endpoint.inputValue()).toBe('https://messages.test/anthropic/v1')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(settingsDocument).not.toContain('searchProvider: deepseek-search-api')
|
||||
await expect.poll(settingsDocument).not.toContain('fetchProvider: deepseek-fetch-api')
|
||||
expect(await settingsDocument()).toContain('baseURL: https://messages.test/anthropic/v1')
|
||||
|
||||
await choose(searchMenu, 'DeepSeek(旧版)')
|
||||
const messagesEndpoint = panel.locator('#plugin-config-web-search-endpoint')
|
||||
expect(await panel.getByLabel('单次请求最多搜索次数', { exact: true }).inputValue()).toBe('5')
|
||||
await messagesEndpoint.fill('https://messages.test/anthropic/v1')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(settingsDocument).toContain('searchProvider: deepseek-official')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openPlugins()
|
||||
await openPage(panel, '网页搜索与获取')
|
||||
expect(await searchMenu.textContent()).toBe('DeepSeek(旧版)')
|
||||
expect(await messagesEndpoint.inputValue()).toBe('https://messages.test/anthropic/v1')
|
||||
expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)'])
|
||||
expect(await save.isDisabled()).toBe(true)
|
||||
expect(await settingsDocument()).toContain('searchProvider: deepseek-official')
|
||||
await choose(searchMenu, 'DeepSeek Search API')
|
||||
await count.fill('20')
|
||||
await save.click()
|
||||
await expect.poll(() => save.isDisabled()).toBe(true)
|
||||
await expect.poll(settingsDocument).not.toContain('searchProvider:')
|
||||
expect(await settingsDocument()).toContain('count: 20')
|
||||
expect(await settingsDocument()).not.toMatch(/ApiKeyEnv:|searchBaseURL:|fetchBaseURL:/)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -1166,7 +1166,7 @@ describe('web e2e: plugin manager', () => {
|
||||
// Schedule ships in the delivered composition, so no bundle card carries it in either language.
|
||||
expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0)
|
||||
// The official configuration pages follow the language too, from their own dictionary.
|
||||
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
|
||||
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search and fetch']) {
|
||||
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
|
||||
}
|
||||
} finally {
|
||||
|
||||
+22
-28
@@ -396,29 +396,25 @@ export interface LaunchOptions {
|
||||
welcomeNoticePending?: boolean
|
||||
/** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */
|
||||
firstUse?: boolean
|
||||
/**
|
||||
* Patch the shipped DeepSeek search row to a deterministic endpoint and
|
||||
* credential reference. Browser search scenarios keep the real provider and
|
||||
* credentials seam while avoiding external search traffic and ambient keys.
|
||||
*/
|
||||
/** Pin the legacy Messages search endpoint and credential reference. */
|
||||
deepSeekSearch?: {
|
||||
/** Anthropic-compatible base URL; the provider appends `/messages`. */
|
||||
/** Anthropic-compatible base URL; `/messages` is appended. */
|
||||
baseURL?: string
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
/** Credential reference read for each search. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/**
|
||||
* Patch the shipped DeepSeek Search and Fetch API row to a deterministic
|
||||
* endpoint and credential reference, optionally selecting both providers as
|
||||
* the settings switch does.
|
||||
* Give both sections of the shipped DeepSeek Search and Fetch API row their
|
||||
* own credential reference and, when given, endpoint, so neither follows the
|
||||
* conversation's DeepSeek credential. Browser search scenarios keep the real
|
||||
* providers and credentials seam while avoiding external search traffic and
|
||||
* ambient keys.
|
||||
*/
|
||||
deepSeekWebApi?: {
|
||||
/** Search and Fetch API base URL; omitted uses the provider default. The providers append `/search` or `/fetch`. */
|
||||
/** Base URL of both sections; omitted uses the official base. The providers append `/search` or `/fetch`. */
|
||||
baseURL?: string
|
||||
/** Credential reference resolved by both providers. */
|
||||
/** Credential reference of both sections. */
|
||||
apiKeyEnv: string
|
||||
/** Select `deepseek-search-api` and `deepseek-fetch-api` in place of the shipped selection. */
|
||||
select?: boolean
|
||||
}
|
||||
/** Preset selection default and additional declarative definitions for this scenario. */
|
||||
agentPresets?: {
|
||||
@@ -690,27 +686,25 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
{ id: 'agent-preset-registry', config: { default: options.agentPresets.default } },
|
||||
{ insert: (options.agentPresets.definitions ?? []).map(config => ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) },
|
||||
],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL },
|
||||
},
|
||||
}],
|
||||
...options.deepSeekWebApi === undefined ? [] : [
|
||||
{
|
||||
id: 'web-deepseek-api',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekWebApi.apiKeyEnv,
|
||||
...options.deepSeekWebApi.baseURL === undefined ? {} : { baseURL: options.deepSeekWebApi.baseURL },
|
||||
searchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv,
|
||||
fetchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv,
|
||||
...options.deepSeekWebApi.baseURL === undefined ? {} : {
|
||||
searchBaseURL: options.deepSeekWebApi.baseURL, fetchBaseURL: options.deepSeekWebApi.baseURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
...options.deepSeekWebApi.select === true
|
||||
? [{ id: 'web', config: { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } }]
|
||||
: [],
|
||||
],
|
||||
...options.deepSeekSearch === undefined ? [] : [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL },
|
||||
},
|
||||
}],
|
||||
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Web e2e scenario for the shipped default search composition. A real browser
|
||||
// drives `web_search`; the model stream is replayed while the real DeepSeek
|
||||
// provider calls a deterministic local Search API endpoint through
|
||||
// the real credentials service.
|
||||
// Web e2e scenario for the shipped default search provider, the DeepSeek
|
||||
// Search API, with no provider selection patched. A real browser drives
|
||||
// `web_search`; the model stream is replayed while the real DeepSeek provider
|
||||
// calls a deterministic local Search API endpoint through the real
|
||||
// credentials service.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
@@ -140,7 +141,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
|
||||
return { server, baseURL: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
describe('web e2e: opt-in Search API web search', () => {
|
||||
describe('web e2e: default Search API web search', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -155,10 +156,10 @@ describe('web e2e: opt-in Search API web search', () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
compareReplaySession: true,
|
||||
// Own YAML endpoint settings pin Search to the local endpoint and a dedicated key.
|
||||
deepSeekWebApi: {
|
||||
baseURL: `${search.baseURL}/api/v1/tools`,
|
||||
apiKeyEnv: SEARCH_CREDENTIAL_REF,
|
||||
select: true,
|
||||
},
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
|
||||
})
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 3c972fccec17a890
|
||||
zh: 94c82847a3c8b83c
|
||||
en: 278dd0c19178635e
|
||||
zh: 5c4abda8eb8273a7
|
||||
|
||||
@@ -280,8 +280,6 @@ flowchart LR
|
||||
pkg_tool_jobs["tool-jobs"]
|
||||
pkg_web["web"]
|
||||
svc_web["ctx.web<br/>Web access provider registry"]
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
pkg_web_search_deepseek["web-search-deepseek"]
|
||||
pkg_web_deepseek_api["web-deepseek-api"]
|
||||
pkg_web_fetch_http["web-fetch-http"]
|
||||
@@ -474,8 +472,6 @@ flowchart LR
|
||||
pkg_web_deepseek_api --> svc_web
|
||||
pkg_web_fetch_http --> svc_web
|
||||
pkg_web_search_deepseek --> svc_web
|
||||
pkg_web_search_exa --> svc_web
|
||||
pkg_web_search_perplexity --> svc_web
|
||||
pkg_webhook --> svc_webhookRuntime
|
||||
pkg_workflow --> svc_workflowEngine
|
||||
pkg_workflow_ptc --> svc_workflowEngine
|
||||
@@ -746,7 +742,7 @@ flowchart LR
|
||||
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
|
||||
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
|
||||
| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). |
|
||||
| `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
|
||||
|
||||
@@ -282,8 +282,6 @@ flowchart LR
|
||||
pkg_tool_jobs["tool-jobs"]
|
||||
pkg_web["web"]
|
||||
svc_web["ctx.web<br/>Web access provider registry"]
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
pkg_web_search_deepseek["web-search-deepseek"]
|
||||
pkg_web_deepseek_api["web-deepseek-api"]
|
||||
pkg_web_fetch_http["web-fetch-http"]
|
||||
@@ -476,8 +474,6 @@ flowchart LR
|
||||
pkg_web_deepseek_api --> svc_web
|
||||
pkg_web_fetch_http --> svc_web
|
||||
pkg_web_search_deepseek --> svc_web
|
||||
pkg_web_search_exa --> svc_web
|
||||
pkg_web_search_perplexity --> svc_web
|
||||
pkg_webhook --> svc_webhookRuntime
|
||||
pkg_workflow --> svc_workflowEngine
|
||||
pkg_workflow_ptc --> svc_workflowEngine
|
||||
@@ -748,7 +744,7 @@ flowchart LR
|
||||
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
|
||||
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
|
||||
| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 |
|
||||
| `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 |
|
||||
|
||||
+23
-60
@@ -4571,7 +4571,7 @@ export type ApprovalPolicy = 'ask' | 'never'
|
||||
## `@deepseek-ai/dsh-web`
|
||||
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts)
|
||||
- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
@@ -4630,17 +4630,16 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:38`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Settings read by the next search or fetch without re-registering either provider. */
|
||||
/**
|
||||
* 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
|
||||
* credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load
|
||||
* without error and have no effect.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey: Volatile<string | undefined>
|
||||
/** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv: Volatile<string>
|
||||
/** API base including its path prefix; `/search` and `/fetch` are appended. */
|
||||
baseURL: Volatile<string>
|
||||
/** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */
|
||||
count: Volatile<number>
|
||||
/**
|
||||
@@ -4651,6 +4650,20 @@ export interface Config {
|
||||
searchRequestsPerSecond: Volatile<number>
|
||||
/** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */
|
||||
sendOriginalQuestion: Volatile<boolean>
|
||||
/**
|
||||
* Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the
|
||||
* official base applies.
|
||||
*/
|
||||
searchBaseURL: Volatile<string | undefined>
|
||||
/** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
|
||||
searchApiKeyEnv: Volatile<string | undefined>
|
||||
/**
|
||||
* Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the
|
||||
* official base applies.
|
||||
*/
|
||||
fetchBaseURL: Volatile<string | undefined>
|
||||
/** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
|
||||
fetchApiKeyEnv: Volatile<string | undefined>
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-deepseek-api -->
|
||||
@@ -4687,7 +4700,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
@@ -4710,56 +4723,6 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-deepseek -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-web-search-exa -->
|
||||
<a id="deepseek-aidsh-web-search-exa"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
- `inject`: `web`
|
||||
- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/search` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
|
||||
searchType?: 'auto' | 'keyword' | 'neural'
|
||||
/** Default result count when a request carries no `maxResults`. Omitted = none. */
|
||||
numResults?: number
|
||||
/** Highlight sentences requested per result. Defaults to 1. */
|
||||
highlightsPerResult?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-exa -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-web-search-perplexity -->
|
||||
<a id="deepseek-aidsh-web-search-perplexity"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-perplexity`
|
||||
|
||||
- `inject`: `web`
|
||||
- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Search model name. Defaults to `sonar`. */
|
||||
model?: string
|
||||
/** Upper bound on generated answer tokens. Defaults to 1024. */
|
||||
maxTokens?: number
|
||||
/** Recency window sent as `search_recency_filter`. Omitted = no filter. */
|
||||
searchRecency?: 'day' | 'week' | 'month' | 'year'
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-perplexity -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-webhook-github -->
|
||||
<a id="deepseek-aidsh-webhook-github"></a>
|
||||
|
||||
|
||||
+23
-60
@@ -4573,7 +4573,7 @@ export type ApprovalPolicy = 'ask' | 'never'
|
||||
## `@deepseek-ai/dsh-web`
|
||||
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts)
|
||||
- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
@@ -4632,17 +4632,16 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:38`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Settings read by the next search or fetch without re-registering either provider. */
|
||||
/**
|
||||
* 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
|
||||
* credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load
|
||||
* without error and have no effect.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
|
||||
apiKey: Volatile<string | undefined>
|
||||
/** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */
|
||||
apiKeyEnv: Volatile<string>
|
||||
/** API base including its path prefix; `/search` and `/fetch` are appended. */
|
||||
baseURL: Volatile<string>
|
||||
/** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */
|
||||
count: Volatile<number>
|
||||
/**
|
||||
@@ -4653,6 +4652,20 @@ export interface Config {
|
||||
searchRequestsPerSecond: Volatile<number>
|
||||
/** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */
|
||||
sendOriginalQuestion: Volatile<boolean>
|
||||
/**
|
||||
* Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the
|
||||
* official base applies.
|
||||
*/
|
||||
searchBaseURL: Volatile<string | undefined>
|
||||
/** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
|
||||
searchApiKeyEnv: Volatile<string | undefined>
|
||||
/**
|
||||
* Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the
|
||||
* official base applies.
|
||||
*/
|
||||
fetchBaseURL: Volatile<string | undefined>
|
||||
/** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */
|
||||
fetchApiKeyEnv: Volatile<string | undefined>
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-deepseek-api -->
|
||||
@@ -4689,7 +4702,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
@@ -4712,56 +4725,6 @@ export interface Config {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-deepseek -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-web-search-exa -->
|
||||
<a id="deepseek-aidsh-web-search-exa"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
- `inject`: `web`
|
||||
- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/search` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
|
||||
searchType?: 'auto' | 'keyword' | 'neural'
|
||||
/** Default result count when a request carries no `maxResults`. Omitted = none. */
|
||||
numResults?: number
|
||||
/** Highlight sentences requested per result. Defaults to 1. */
|
||||
highlightsPerResult?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-exa -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-web-search-perplexity -->
|
||||
<a id="deepseek-aidsh-web-search-perplexity"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-perplexity`
|
||||
|
||||
- `inject`: `web`
|
||||
- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Search model name. Defaults to `sonar`. */
|
||||
model?: string
|
||||
/** Upper bound on generated answer tokens. Defaults to 1024. */
|
||||
maxTokens?: number
|
||||
/** Recency window sent as `search_recency_filter`. Omitted = no filter. */
|
||||
searchRecency?: 'day' | 'week' | 'month' | 'year'
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-web-search-perplexity -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-webhook-github -->
|
||||
<a id="deepseek-aidsh-webhook-github"></a>
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
+13
-16
@@ -93,8 +93,6 @@ flowchart TD
|
||||
pkg_web_deepseek_api["web-deepseek-api"]
|
||||
pkg_web_fetch_http["web-fetch-http"]
|
||||
pkg_web_search_deepseek["web-search-deepseek"]
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
end
|
||||
subgraph group_spill["packages/spill"]
|
||||
pkg_spill["spill"]
|
||||
@@ -517,10 +515,6 @@ flowchart TD
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
pkg_web_search_exa --> pkg_launch_environment
|
||||
pkg_web_search_exa --> pkg_web
|
||||
pkg_web_search_perplexity --> pkg_launch_environment
|
||||
pkg_web_search_perplexity --> pkg_web
|
||||
pkg_spill --> pkg_brand
|
||||
pkg_spill --> pkg_llm
|
||||
pkg_spill --> pkg_session
|
||||
@@ -767,16 +761,11 @@ flowchart TD
|
||||
pkg_fs_sandbox --> pkg_fs_local
|
||||
pkg_fs_sandbox --> pkg_sandbox
|
||||
pkg_fs_sandbox --> pkg_sandbox_policy
|
||||
pkg_web_deepseek_api --> pkg_agent
|
||||
pkg_web_deepseek_api --> pkg_credentials
|
||||
pkg_web_deepseek_api --> pkg_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_launch_environment
|
||||
pkg_web_deepseek_api --> pkg_session
|
||||
pkg_web_deepseek_api --> pkg_web
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_deepseek_account
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_llm_deepseek
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_api_account_controller --> pkg_agent
|
||||
@@ -904,6 +893,16 @@ flowchart TD
|
||||
pkg_tool_web --> pkg_system_prompt
|
||||
pkg_tool_web --> pkg_tools
|
||||
pkg_tool_web --> pkg_web
|
||||
pkg_web_deepseek_api --> pkg_agent
|
||||
pkg_web_deepseek_api --> pkg_credentials
|
||||
pkg_web_deepseek_api --> pkg_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_launch_environment
|
||||
pkg_web_deepseek_api --> pkg_llm
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek_api_key
|
||||
pkg_web_deepseek_api --> pkg_session
|
||||
pkg_web_deepseek_api --> pkg_web
|
||||
pkg_tool_todo --> pkg_agent
|
||||
pkg_tool_todo --> pkg_session
|
||||
pkg_tool_todo --> pkg_session_projection
|
||||
@@ -1681,8 +1680,6 @@ flowchart TD
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
@@ -1759,8 +1756,7 @@ flowchart TD
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
|
||||
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
|
||||
| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
|
||||
@@ -1785,6 +1781,7 @@ flowchart TD
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) |
|
||||
|
||||
+13
-16
@@ -95,8 +95,6 @@ flowchart TD
|
||||
pkg_web_deepseek_api["web-deepseek-api"]
|
||||
pkg_web_fetch_http["web-fetch-http"]
|
||||
pkg_web_search_deepseek["web-search-deepseek"]
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
end
|
||||
subgraph group_spill["packages/spill"]
|
||||
pkg_spill["spill"]
|
||||
@@ -519,10 +517,6 @@ flowchart TD
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
pkg_web_search_exa --> pkg_launch_environment
|
||||
pkg_web_search_exa --> pkg_web
|
||||
pkg_web_search_perplexity --> pkg_launch_environment
|
||||
pkg_web_search_perplexity --> pkg_web
|
||||
pkg_spill --> pkg_brand
|
||||
pkg_spill --> pkg_llm
|
||||
pkg_spill --> pkg_session
|
||||
@@ -769,16 +763,11 @@ flowchart TD
|
||||
pkg_fs_sandbox --> pkg_fs_local
|
||||
pkg_fs_sandbox --> pkg_sandbox
|
||||
pkg_fs_sandbox --> pkg_sandbox_policy
|
||||
pkg_web_deepseek_api --> pkg_agent
|
||||
pkg_web_deepseek_api --> pkg_credentials
|
||||
pkg_web_deepseek_api --> pkg_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_launch_environment
|
||||
pkg_web_deepseek_api --> pkg_session
|
||||
pkg_web_deepseek_api --> pkg_web
|
||||
pkg_web_search_deepseek --> pkg_agent
|
||||
pkg_web_search_deepseek --> pkg_credentials
|
||||
pkg_web_search_deepseek --> pkg_deepseek_account
|
||||
pkg_web_search_deepseek --> pkg_launch_environment
|
||||
pkg_web_search_deepseek --> pkg_llm_deepseek
|
||||
pkg_web_search_deepseek --> pkg_session
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_api_account_controller --> pkg_agent
|
||||
@@ -906,6 +895,16 @@ flowchart TD
|
||||
pkg_tool_web --> pkg_system_prompt
|
||||
pkg_tool_web --> pkg_tools
|
||||
pkg_tool_web --> pkg_web
|
||||
pkg_web_deepseek_api --> pkg_agent
|
||||
pkg_web_deepseek_api --> pkg_credentials
|
||||
pkg_web_deepseek_api --> pkg_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_launch_environment
|
||||
pkg_web_deepseek_api --> pkg_llm
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek_account
|
||||
pkg_web_deepseek_api --> pkg_llm_deepseek_api_key
|
||||
pkg_web_deepseek_api --> pkg_session
|
||||
pkg_web_deepseek_api --> pkg_web
|
||||
pkg_tool_todo --> pkg_agent
|
||||
pkg_tool_todo --> pkg_session
|
||||
pkg_tool_todo --> pkg_session_projection
|
||||
@@ -1683,8 +1682,6 @@ flowchart TD
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
@@ -1761,8 +1758,7 @@ flowchart TD
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
|
||||
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
|
||||
| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
|
||||
@@ -1787,6 +1783,7 @@ flowchart TD
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) |
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
|
||||
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) |
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web.md
|
||||
/web-access:
|
||||
en: 86d12e6e81fb63b8
|
||||
zh: 52ed66dcacaf6d85
|
||||
en: 3186d79f26bd8c45
|
||||
zh: de31c570ea8be79c
|
||||
/web-access/why-one-capability-has-two-operations:
|
||||
en: 380da3cd0aabd52b
|
||||
zh: 1317c26089c3ab07
|
||||
@@ -15,8 +15,8 @@
|
||||
en: b96bcdf42ced9eab
|
||||
zh: aa927e2450238557
|
||||
/web-access/provider-availability:
|
||||
en: 38515cfa348e0950
|
||||
zh: 890b624c15d8aa41
|
||||
en: ba99c3876cb05fd9
|
||||
zh: cd366d7c3279bd87
|
||||
/web-access/fetch-network-policy:
|
||||
en: 6851874be5841d90
|
||||
zh: dc085bf3b82d77df
|
||||
|
||||
+50
-17
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web.zh.md)
|
||||
|
||||
The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL.
|
||||
The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL.
|
||||
|
||||
Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts)
|
||||
|
||||
@@ -26,9 +26,9 @@ interface WebSearchRequest {
|
||||
/**
|
||||
* Upper bound on returned sources; the seam truncates to it. Omitted = no
|
||||
* bound. `dsh-tool-web` always sets it. A provider whose API supports a
|
||||
* result-count control (Exa's `numResults`) should apply it at the request
|
||||
* layer as a cost/latency optimization; the seam enforces the bound
|
||||
* regardless.
|
||||
* result-count control (the DeepSeek Search API's `count`) should apply it
|
||||
* at the request layer as a cost/latency optimization; the seam enforces the
|
||||
* bound regardless.
|
||||
*/
|
||||
readonly maxResults?: number
|
||||
}
|
||||
@@ -37,8 +37,8 @@ interface WebSearchRequest {
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Normalized search outcome. `content` is optional provider-generated answer
|
||||
* text or summary (Exa and DeepSeek return none; Perplexity returns a
|
||||
* generated answer).
|
||||
* text or summary (DeepSeek returns none; a provider whose API generates an
|
||||
* answer returns it here).
|
||||
* `sources[]` is the portable citation shape. `truncated` is set by the seam
|
||||
* when it cut `sources[]` down to `maxResults`.
|
||||
*/
|
||||
@@ -56,8 +56,8 @@ interface WebSearchResult {
|
||||
/**
|
||||
* One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
|
||||
* and `publishedAt` are optional because not every provider returns them — forcing
|
||||
* adapters to invent them would make the seam lie (Perplexity citations may be
|
||||
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
|
||||
* adapters to invent them would make the seam lie (some providers return
|
||||
* URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display.
|
||||
*/
|
||||
interface WebSearchSource {
|
||||
readonly url: string
|
||||
@@ -123,9 +123,9 @@ type WebFetchBody =
|
||||
|
||||
## Provider availability
|
||||
|
||||
A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
|
||||
A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. Registration, unlike availability, is observable before an operation runs: `listProviders()` returns every registered search and fetch provider by `id`, optional `title`, and optional localized `description`, and the payload-free `web/providers-changed` event fires after each registration or disposal; availability still shows only when an operation runs.
|
||||
|
||||
A search or fetch provider may also implement `ready(llmProvider): Promise<boolean>`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool.
|
||||
A search or fetch provider may also implement `ready(llmProvider): Promise<boolean>`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool. The DeepSeek Search and Fetch API providers use the route to choose between the account token and the API key, as the [web-deepseek-api authentication](../../packages/web/web-deepseek-api/README.md#authentication) section describes.
|
||||
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. Each operation reads the configured id when it runs, so a saved settings change applies to the next search or fetch without remounting `ctx.web`.
|
||||
|
||||
@@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.web` — `WebRuntime`
|
||||
|
||||
The web access service. Registered as `ctx.web` (one instance per context).
|
||||
The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`).
|
||||
|
||||
Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
@@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* Register a search provider. `listProviders()` lists it until the
|
||||
* registration is disposed, which happens with the calling fiber or through
|
||||
* the returned disposer; registration and disposal each emit
|
||||
* `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
|
||||
/**
|
||||
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for fetch. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* Register a fetch provider, with the listing, disposal, and event behavior
|
||||
* of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError}
|
||||
* `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
|
||||
/**
|
||||
* List every registered search and fetch provider for provider pickers.
|
||||
* Availability does not filter the lists.
|
||||
* @returns detached `{ id, title, description }` entries per capability, in registration order.
|
||||
*/
|
||||
@Remote listProviders(): WebProviderList
|
||||
|
||||
/**
|
||||
* Run one search through the selected provider. Resolves the provider at call
|
||||
* time with the selection rules above; throws {@link WebError} when the
|
||||
@@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise<boolean>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)
|
||||
|
||||
<a id="web-events"></a>
|
||||
|
||||
### `web/*` events
|
||||
|
||||
<a id="webproviders-changed--emit"></a>
|
||||
|
||||
#### `web/providers-changed` — emit
|
||||
|
||||
The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The set of registered search or fetch providers changed: one provider
|
||||
* registered, or its registration was disposed. Payload-free; consumers
|
||||
* re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the
|
||||
* new list. Fires after each committed registry mutation. Observer failures
|
||||
* are contained and cannot veto the mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'web/providers-changed'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
+50
-17
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web.md) | 中文
|
||||
|
||||
Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。
|
||||
Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。
|
||||
|
||||
源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts)
|
||||
|
||||
@@ -26,9 +26,9 @@ interface WebSearchRequest {
|
||||
/**
|
||||
* Upper bound on returned sources; the seam truncates to it. Omitted = no
|
||||
* bound. `dsh-tool-web` always sets it. A provider whose API supports a
|
||||
* result-count control (Exa's `numResults`) should apply it at the request
|
||||
* layer as a cost/latency optimization; the seam enforces the bound
|
||||
* regardless.
|
||||
* result-count control (the DeepSeek Search API's `count`) should apply it
|
||||
* at the request layer as a cost/latency optimization; the seam enforces the
|
||||
* bound regardless.
|
||||
*/
|
||||
readonly maxResults?: number
|
||||
}
|
||||
@@ -37,8 +37,8 @@ interface WebSearchRequest {
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Normalized search outcome. `content` is optional provider-generated answer
|
||||
* text or summary (Exa and DeepSeek return none; Perplexity returns a
|
||||
* generated answer).
|
||||
* text or summary (DeepSeek returns none; a provider whose API generates an
|
||||
* answer returns it here).
|
||||
* `sources[]` is the portable citation shape. `truncated` is set by the seam
|
||||
* when it cut `sources[]` down to `maxResults`.
|
||||
*/
|
||||
@@ -56,8 +56,8 @@ interface WebSearchResult {
|
||||
/**
|
||||
* One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
|
||||
* and `publishedAt` are optional because not every provider returns them — forcing
|
||||
* adapters to invent them would make the seam lie (Perplexity citations may be
|
||||
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
|
||||
* adapters to invent them would make the seam lie (some providers return
|
||||
* URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display.
|
||||
*/
|
||||
interface WebSearchSource {
|
||||
readonly url: string
|
||||
@@ -123,9 +123,9 @@ type WebFetchBody =
|
||||
|
||||
## 提供方可用性
|
||||
|
||||
提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。
|
||||
提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。与可用性不同,注册情况在操作运行前即可观测:`listProviders()` 按 `id`、可选的 `title` 和可选的多语言 `description` 返回每个已注册的搜索与抓取提供方,每次注册或注销后都会触发不带载荷的 `web/providers-changed` 事件;可用性仍只在操作运行时体现。
|
||||
|
||||
搜索或抓取提供方还可以实现 `ready(llmProvider): Promise<boolean>`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。
|
||||
搜索或抓取提供方还可以实现 `ready(llmProvider): Promise<boolean>`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。DeepSeek Search 与 Fetch API 提供方依据该路由在账号令牌与 API 密钥之间选择,见 [web-deepseek-api 鉴权](../../packages/web/web-deepseek-api/README.zh.md#authentication)一节。
|
||||
|
||||
选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。每次操作在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `ctx.web` 即对下一次搜索或抓取生效。
|
||||
|
||||
@@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.web` — `WebRuntime`
|
||||
|
||||
The web access service. Registered as `ctx.web` (one instance per context).
|
||||
The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`).
|
||||
|
||||
Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
@@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* Register a search provider. `listProviders()` lists it until the
|
||||
* registration is disposed, which happens with the calling fiber or through
|
||||
* the returned disposer; registration and disposal each emit
|
||||
* `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
|
||||
/**
|
||||
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for fetch. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* Register a fetch provider, with the listing, disposal, and event behavior
|
||||
* of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError}
|
||||
* `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
|
||||
/**
|
||||
* List every registered search and fetch provider for provider pickers.
|
||||
* Availability does not filter the lists.
|
||||
* @returns detached `{ id, title, description }` entries per capability, in registration order.
|
||||
*/
|
||||
@Remote listProviders(): WebProviderList
|
||||
|
||||
/**
|
||||
* Run one search through the selected provider. Resolves the provider at call
|
||||
* time with the selection rules above; throws {@link WebError} when the
|
||||
@@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise<boolean>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)
|
||||
|
||||
<a id="web-events"></a>
|
||||
|
||||
### `web/*` events
|
||||
|
||||
<a id="webproviders-changed--emit"></a>
|
||||
|
||||
#### `web/providers-changed` — emit
|
||||
|
||||
The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* The set of registered search or fetch providers changed: one provider
|
||||
* registered, or its registration was disposed. Payload-free; consumers
|
||||
* re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the
|
||||
* new list. Fires after each committed registry mutation. Observer failures
|
||||
* are contained and cannot veto the mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'web/providers-changed'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: 0d7399a58529b9eb
|
||||
zh: 7f445b06430f21a4
|
||||
/testing-policy/tiers:
|
||||
en: c024da7e8d9cb056
|
||||
zh: c4cc1a66d2903656
|
||||
en: ae5eb9cc384542cb
|
||||
zh: 3473df2dc1e36bd0
|
||||
/testing-policy/how-specs-execute:
|
||||
en: 022c534e72e23142
|
||||
zh: c07a20994224f37c
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
|
||||
|
||||
- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`).
|
||||
- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate flags for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/shell/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar.
|
||||
- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
|
||||
- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys; each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
|
||||
- **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`.
|
||||
- **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)).
|
||||
- **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.<ordinal>[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
- **单元测试**(`pnpm run test`):vitest 运行包和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(对向该注册表贡献内容的 fiber 执行 dispose(资源释放),并断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。
|
||||
- **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/shell/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其执行器套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。
|
||||
- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。
|
||||
- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制,缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。
|
||||
- **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。
|
||||
- **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。
|
||||
- **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.<ordinal>[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。
|
||||
|
||||
@@ -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-alpha.2/web-search-credential-modes/guide.md
|
||||
/:
|
||||
en: 5b07a9a586fe0ef8
|
||||
zh: 957123dc3a23c9a9
|
||||
/deepseek-web-search-and-fetch-follow-the-deepseek-model-route:
|
||||
en: bf3ccc3a2347cdbf
|
||||
zh: ff4557b79d972e56
|
||||
/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/change:
|
||||
en: ff12048c0d6a6e11
|
||||
zh: 159e178776739bbe
|
||||
/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/migration:
|
||||
en: 97da49ad7b5342fa
|
||||
zh: ce0f49d638045891
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "DeepSeek web search and fetch take their endpoint from the DeepSeek model route; the shared baseURL, apiKeyEnv, and apiKey fields are removed in favor of per-capability fields."
|
||||
---
|
||||
|
||||
# DeepSeek web search and fetch follow the DeepSeek model route
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Previously, the DeepSeek Search API and Fetch API (`web-deepseek-api`) sent their credential to one shared `baseURL` (default `https://api.deepseek.com/v1/tools`): the account token when the Session's latest model request used the `deepseek-account` route and the user was signed in, otherwise the key under `apiKeyEnv` (default `DEEPSEEK_API_KEY`) or a literal `apiKey`. Now each capability follows the same route rule but takes the endpoint from the route: on `deepseek-account` the account token goes to the account route's origin with `/v1/tools`; otherwise, including a signed-out account conversation, the key under the `apiKeyEnv` of the `deepseek-official` route goes to that route's origin with `/v1/tools`, so `DEEPSEEK_BASE_URL` now moves Search and Fetch too. Without a `deepseek-official` route, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`; a mounted route whose settings do not resolve fails the operation instead. The `baseURL`, `apiKeyEnv`, and `apiKey` fields are removed; stored values load without error and are ignored. Setting either `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) pins that capability to its own endpoint (default `https://api.deepseek.com/v1/tools`) and key (default `DEEPSEEK_API_KEY`) and never sends the account token. These fields are YAML-only. Users without those YAML fields, without `DEEPSEEK_BASE_URL`, and without a custom model base URL see no change.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Users who set `baseURL`, `apiKeyEnv`, or a literal `apiKey` on the `web-deepseek-api` row: for each of Search and Fetch, compare the old endpoint and key with what the route now sends, the origin of the DeepSeek model provider's base URL with `/v1/tools` and that provider's `apiKeyEnv`. When both match, remove the old fields. Otherwise set the pair, `searchBaseURL` with `searchApiKeyEnv` (`fetchBaseURL` with `fetchApiKeyEnv`), to the old endpoint and key reference, setting both fields even when one old value equals the route's: an unset field of the pair falls back to `https://api.deepseek.com/v1/tools` or `DEEPSEEK_API_KEY`, never to the route's value. For a literal `apiKey`, store it in the credential store or the launching environment under a reference and use that reference. A pinned capability never sends the account token. Confirm: `dsh` starts, and a search and a fetch reach the intended endpoint without a credential error.
|
||||
2. Users with `DEEPSEEK_BASE_URL` or a custom base URL on the DeepSeek model provider whose host does not serve `/v1/tools`: set the same pair to `https://api.deepseek.com/v1/tools` and the reference of a key that endpoint accepts. Confirm: a search returns results.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "DeepSeek 网页搜索与获取从 DeepSeek 模型路由取得端点;共享的 baseURL、apiKeyEnv 与 apiKey 字段已移除,改用按能力区分的字段。"
|
||||
---
|
||||
|
||||
# DeepSeek 网页搜索与获取跟随 DeepSeek 模型路由
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
过去,DeepSeek Search API 与 Fetch API(`web-deepseek-api`)把凭据发往同一个共享的 `baseURL`(默认 `https://api.deepseek.com/v1/tools`):会话最近一次模型请求使用 `deepseek-account` 路由且用户已登录时发送账号令牌,否则发送 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)下的密钥或字面 `apiKey`。现在每个能力沿用相同的路由规则,但端点取自路由:在 `deepseek-account` 上把账号令牌发往账号路由的 origin 加 `/v1/tools`;其他情况(包括未登录的账号会话)把 `deepseek-official` 路由 `apiKeyEnv` 下的密钥发往该路由的 origin 加 `/v1/tools`,因此 `DEEPSEEK_BASE_URL` 现在也会改变 Search 与 Fetch 的端点。没有 `deepseek-official` 路由时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`;已挂载的路由设置无法解析时,操作直接失败。`baseURL`、`apiKeyEnv` 与 `apiKey` 字段已移除;已保存的值可以正常加载,但会被忽略。只要设置 `searchBaseURL` 或 `searchApiKeyEnv` 中的任一项(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`),就会把该能力固定到其自己的端点(默认 `https://api.deepseek.com/v1/tools`)和密钥(默认 `DEEPSEEK_API_KEY`),且从不发送账号令牌。这些字段只能在 YAML 中设置。没有设置这些 YAML 字段、没有设置 `DEEPSEEK_BASE_URL`、也没有自定义模型基础地址的用户不受影响。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在 `web-deepseek-api` 条目上设置过 `baseURL`、`apiKeyEnv` 或字面 `apiKey` 的用户:对 Search 与 Fetch 分别比较原端点和密钥与路由现在发送的值,即 DeepSeek 模型提供方基础地址的 origin 加 `/v1/tools`,以及该提供方的 `apiKeyEnv`。两者都一致时,删除旧字段。否则把成对字段 `searchBaseURL` 与 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 与 `fetchApiKeyEnv`)设为原端点和原密钥引用;即使其中一个原值与路由的值相同,也要两个字段都设置:成对字段中未设置的一项回退到 `https://api.deepseek.com/v1/tools` 或 `DEEPSEEK_API_KEY`,不会继承路由的值。设置过字面 `apiKey` 时,把该值以某个引用存入凭据存储或启动环境,并使用该引用。单独配置的能力从不发送账号令牌。确认:`dsh` 正常启动,且搜索和抓取都到达预期端点,没有凭据错误。
|
||||
2. 设置了 `DEEPSEEK_BASE_URL` 或为 DeepSeek 模型提供方设置了自定义基础地址、且该主机不提供 `/v1/tools` 的用户:把同一对字段设为 `https://api.deepseek.com/v1/tools` 和该端点接受的密钥引用。确认:搜索返回结果。
|
||||
@@ -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-alpha.2/web-search-default-provider/guide.md
|
||||
/:
|
||||
en: 2a8012259f83b47f
|
||||
zh: 913cf3290acedfd0
|
||||
/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed:
|
||||
en: d74d9ae56bde707f
|
||||
zh: 45eafef25e659780
|
||||
/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/change:
|
||||
en: acb404257af50295
|
||||
zh: c22c29e18fc1dbab
|
||||
/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/migration:
|
||||
en: 5f69931cdd39ea2f
|
||||
zh: 7e569007c068151c
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The shipped Search default moves from legacy Messages search to the DeepSeek Search API, and the optional Exa and Perplexity search packages are removed."
|
||||
---
|
||||
|
||||
# Search defaults to the DeepSeek Search API; Exa and Perplexity are removed
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Previously, the shared base composition set `web.searchProvider` to `deepseek-official`: each search was an auxiliary Messages request to `web-search-deepseek`'s endpoint, sending `Perform a web search for the query: <query>` to `deepseek-v4-flash` with a server-side `web_search` tool, billed as model tokens. Now it sets `deepseek-search-api`: each search is one direct request to the DeepSeek Search API, without a model request, and may incur Search API charges instead of model tokens. That request sends `query`, `count`, `need_content: false`, and, unless `web-deepseek-api.sendOriginalQuestion` is `false`, the user's latest prompt cut to 128 characters as `ori_question`. Its credential and endpoint follow the [DeepSeek model route](../web-search-credential-modes/guide.md). Compositions that set `web.searchProvider` themselves see no change, and Fetch still defaults to `http`.
|
||||
|
||||
The optional packages `@deepseek-ai/dsh-web-search-exa` (provider `exa`) and `@deepseek-ai/dsh-web-search-perplexity` (provider `perplexity`) are removed. A row naming either package no longer loads, and `searchProvider: exa` or `searchProvider: perplexity` names no provider.
|
||||
|
||||
## Migration
|
||||
|
||||
1. To keep legacy Messages search, set `searchProvider: deepseek-official` in the `config` of the `@deepseek-ai/dsh-web` row, or select DeepSeek (legacy) in the Web search provider menu on the Web search and fetch settings page. Confirm: that menu shows DeepSeek (legacy), and a search appends a `web/deepseek-search-llm-request` event to the Session log.
|
||||
2. Deployments that mounted Exa or Perplexity: delete the `@deepseek-ai/dsh-web-search-exa` or `@deepseek-ai/dsh-web-search-perplexity` row from `cordis.yml` or the profile patch, remove `EXA_API_KEY` or `PERPLEXITY_API_KEY` if nothing else uses it, and set `searchProvider` to `deepseek-search-api` or `deepseek-official`, or delete it to take the default. Confirm: `dsh` starts without a load error for either package, the Web search provider menu shows the chosen provider, and a search returns results.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "交付的 Search 默认值从旧版 Messages 搜索改为 DeepSeek Search API,可选的 Exa 与 Perplexity 搜索包已移除。"
|
||||
---
|
||||
|
||||
# Search 默认使用 DeepSeek Search API;Exa 与 Perplexity 已移除
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
过去,共享的基础组合把 `web.searchProvider` 设为 `deepseek-official`:每次搜索都是发往 `web-search-deepseek` 端点的一次辅助 Messages 请求,以 `Perform a web search for the query: <query>` 调用带服务端 `web_search` 工具的 `deepseek-v4-flash`,按模型 token 计费。现在它设为 `deepseek-search-api`:每次搜索是一次直接发往 DeepSeek Search API 的请求,不调用模型,可能产生 Search API 费用而非模型 token 费用。该请求发送 `query`、`count`、`need_content: false`,并且除非 `web-deepseek-api.sendOriginalQuestion` 为 `false`,还会把用户最近一条提示截取前 128 个字符作为 `ori_question` 发送。其凭据和端点跟随 [DeepSeek 模型路由](../web-search-credential-modes/guide.zh.md)。自行设置了 `web.searchProvider` 的组合不受影响,Fetch 仍默认为 `http`。
|
||||
|
||||
可选包 `@deepseek-ai/dsh-web-search-exa`(提供方 `exa`)与 `@deepseek-ai/dsh-web-search-perplexity`(提供方 `perplexity`)已移除。指名任一包的条目不再能加载,`searchProvider: exa` 或 `searchProvider: perplexity` 也不再对应任何提供方。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 要继续使用旧版 Messages 搜索,在 `@deepseek-ai/dsh-web` 条目的 `config` 中设置 `searchProvider: deepseek-official`,或在「网页搜索与获取」设置页的「网页搜索提供方」菜单中选择 DeepSeek(旧版)。确认:该菜单显示 DeepSeek(旧版),且每次搜索都会向会话日志追加一个 `web/deepseek-search-llm-request` 事件。
|
||||
2. 挂载过 Exa 或 Perplexity 的部署:从 `cordis.yml` 或 profile 补丁中删除 `@deepseek-ai/dsh-web-search-exa` 或 `@deepseek-ai/dsh-web-search-perplexity` 条目;若 `EXA_API_KEY` 或 `PERPLEXITY_API_KEY` 不再被其他地方使用,一并移除;再把 `searchProvider` 设为 `deepseek-search-api` 或 `deepseek-official`,或删除该字段以采用默认值。确认:`dsh` 启动时没有这两个包的加载错误,「网页搜索提供方」菜单显示所选提供方,且搜索返回结果。
|
||||
@@ -15,11 +15,11 @@
|
||||
en: a7a78c6d1aeebeb3
|
||||
zh: 4bb69f32d8b05ba3
|
||||
/deepseek-ai-dsh-api-remotes/use-this-package:
|
||||
en: 6e3448339c646a7e
|
||||
zh: 5dc00ff4cceaa88c
|
||||
en: 5e5770cc1cbf49db
|
||||
zh: b9ee3c309d0cd93c
|
||||
/deepseek-ai-dsh-api-remotes/forwarded-host-events:
|
||||
en: 31bb9a2f50094b84
|
||||
zh: 226c055739d5def7
|
||||
en: 1dea001229f8c06a
|
||||
zh: 3acb1fa1c5d24f8c
|
||||
/deepseek-ai-dsh-api-remotes/build-boundary:
|
||||
en: a73c2b7e1069a750
|
||||
zh: 5b3f87a9ea29cb14
|
||||
|
||||
@@ -29,7 +29,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
|
||||
|
||||
[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller.
|
||||
|
||||
The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
|
||||
The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, web provider list, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
|
||||
|
||||
This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md).
|
||||
|
||||
@@ -48,7 +48,7 @@ An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` ki
|
||||
|
||||
The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams.
|
||||
|
||||
The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence.
|
||||
The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. The payload-free `web/providers-changed` event invalidates the `web.listProviders()` provider lists; Clients re-read after a notification and reconnect.
|
||||
|
||||
<a id="build-boundary"></a>
|
||||
## Build boundary
|
||||
|
||||
@@ -29,7 +29,7 @@ kind: "package-reference"
|
||||
|
||||
[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。
|
||||
|
||||
Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
|
||||
Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents、Web 提供方列表和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
|
||||
|
||||
本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。
|
||||
|
||||
@@ -48,7 +48,7 @@ Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings
|
||||
|
||||
Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。
|
||||
|
||||
无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。
|
||||
无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。无 payload 的 `web/providers-changed` 事件使 `web.listProviders()` 提供方列表失效;Client 在收到通知及重连后重新读取。
|
||||
|
||||
<a id="build-boundary"></a>
|
||||
## 构建边界
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent-team": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:*",
|
||||
"@deepseek-ai/dsh-web": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote'
|
||||
import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
|
||||
import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote'
|
||||
import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
|
||||
import webRemote from '@deepseek-ai/dsh-web/remote'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
|
||||
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
@@ -54,6 +55,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven
|
||||
export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote'
|
||||
export type {} from '@deepseek-ai/dsh-user-questions/remote'
|
||||
export type {} from '@deepseek-ai/dsh-commands/remote'
|
||||
export type {} from '@deepseek-ai/dsh-web/remote'
|
||||
export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
|
||||
export type {} from '@deepseek-ai/dsh-api-account-controller/remote'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
@@ -85,6 +87,7 @@ export type { ApiRemoteForwardedEvent } from '../types.ts'
|
||||
// signatures `$on` hands to a listener, so a consumer reads the very
|
||||
// declaration the Host emits rather than a flattened restatement of it.
|
||||
export type {} from '@deepseek-ai/dsh-commands/types'
|
||||
export type {} from '@deepseek-ai/dsh-web/types'
|
||||
export type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
|
||||
export type {} from '@deepseek-ai/dsh-credentials/types'
|
||||
export type {} from '@deepseek-ai/dsh-llm/types'
|
||||
@@ -197,7 +200,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
messageFeedbackRemote, sessionFeedbackRemote,
|
||||
fileUploadsRemote, sessionReferencesRemote,
|
||||
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
|
||||
officeToPdfRemote, userQuestionsRemote,
|
||||
officeToPdfRemote, userQuestionsRemote, webRemote,
|
||||
]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {} from '@deepseek-ai/dsh-permission-presets/types'
|
||||
import type {} from '@deepseek-ai/dsh-settings/types'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-user-questions'
|
||||
import type {} from '@deepseek-ai/dsh-web/types'
|
||||
export type {} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-plugin-manager/types'
|
||||
import type {} from '@deepseek-ai/dsh-plugin-market/types'
|
||||
import type {} from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-web/types'
|
||||
|
||||
/**
|
||||
* Host events this application forwards without renaming. The explicit mode is
|
||||
@@ -54,4 +55,5 @@ export const API_REMOTE_FORWARDED_EVENTS = [
|
||||
{ event: 'settings/document-updated', mode: 'emit' },
|
||||
{ event: 'schedule/changed', mode: 'emit' },
|
||||
{ event: 'user-questions/request', mode: 'waterfall' },
|
||||
{ event: 'web/providers-changed', mode: 'emit' },
|
||||
] as const satisfies readonly TypertForwardableEventEntry[]
|
||||
|
||||
@@ -133,6 +133,16 @@ describe('Remote event Host source', () => {
|
||||
value: { event: 'schedule/changed', args: [] },
|
||||
})
|
||||
|
||||
emitRaw(ctx, 'web/providers-changed', [])
|
||||
await expect(first.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: { event: 'web/providers-changed', args: [] },
|
||||
})
|
||||
await expect(second.next()).resolves.toEqual({
|
||||
done: false,
|
||||
value: { event: 'web/providers-changed', args: [] },
|
||||
})
|
||||
|
||||
const firstDone = first.next()
|
||||
firstAbort.abort(new Error('first Client disconnected'))
|
||||
emitRaw(ctx, 'commands/change', [])
|
||||
|
||||
@@ -114,6 +114,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../web/web"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../web/web"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -461,21 +461,28 @@
|
||||
|
||||
# The shared base enables the stable model-facing web_search and web_fetch
|
||||
# tools. The Web app disables this host row and composes both tools per agent
|
||||
# preset; products with a stricter network policy override tool-web. DeepSeek
|
||||
# search resolves the same DEEPSEEK_API_KEY
|
||||
# credential the Models page manages for chat, at each search; its Messages
|
||||
# endpoint is configured independently from the model route, so it takes its own
|
||||
# base-URL override. Anonymous fetch accepts only public HTTP(S) destinations,
|
||||
# resolves and validates every destination, and pins every actual connection.
|
||||
# Search is a full auxiliary model request with server-side retrieval, so this
|
||||
# shipped DeepSeek route gets 60s while the provider-neutral tool default
|
||||
# remains 30s. The DeepSeek Search and Fetch APIs register as
|
||||
# deepseek-search-api and deepseek-fetch-api with the same credential; the Web
|
||||
# search settings switch selects both in place of these defaults.
|
||||
# preset; products with a stricter network policy override tool-web. Search
|
||||
# defaults to the DeepSeek Search API (deepseek-search-api) and fetch to the
|
||||
# anonymous local fetcher (http). The DeepSeek Search and Fetch APIs
|
||||
# (deepseek-search-api, deepseek-fetch-api) follow the conversation: on the
|
||||
# deepseek-account route they send the account token to that route's
|
||||
# origin, and otherwise the API key of the DeepSeek API-key route to its
|
||||
# origin, so a DEEPSEEK_API_KEY alone serves both without a sign-in.
|
||||
# searchBaseURL / searchApiKeyEnv (fetch* for Fetch) pin one capability to
|
||||
# its own endpoint and key instead.
|
||||
# Legacy Messages search (deepseek-official) is selectable separately and
|
||||
# uses its own endpoint, with the account token or DEEPSEEK_API_KEY.
|
||||
# Anonymous fetch accepts only public
|
||||
# HTTP(S) destinations, resolves and validates every destination, and pins
|
||||
# every actual connection. This composition gives web_search 60s whichever
|
||||
# provider serves it; the provider-neutral tool default remains 30s. The Web
|
||||
# search provider and Web fetch provider
|
||||
# menus on the Web search and fetch settings page select a provider for each
|
||||
# capability in place of these defaults.
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
searchProvider: deepseek-search-api
|
||||
fetchProvider: http
|
||||
|
||||
- id: web-search-deepseek
|
||||
@@ -485,8 +492,6 @@
|
||||
|
||||
- id: web-deepseek-api
|
||||
name: '@deepseek-ai/dsh-web-deepseek-api'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
|
||||
- id: web-fetch-http
|
||||
name: '@deepseek-ai/dsh-web-fetch-http'
|
||||
|
||||
@@ -41,12 +41,18 @@ describe('dsh-base bundle', () => {
|
||||
})
|
||||
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
|
||||
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
|
||||
expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' })
|
||||
expect(rows.find(row => row.id === 'web')?.config).toEqual({ searchProvider: 'deepseek-search-api', fetchProvider: 'http' })
|
||||
expect(rows.find(row => row.id === 'web-deepseek-api')).toEqual({ id: 'web-deepseek-api', name: '@deepseek-ai/dsh-web-deepseek-api' })
|
||||
expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined()
|
||||
expect(rows.find(row => row.id === 'web-search-deepseek')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek', config: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
|
||||
})
|
||||
expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: true })
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
|
||||
expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http')
|
||||
expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-deepseek-api')
|
||||
expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-search-deepseek')
|
||||
})
|
||||
|
||||
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: b3d0ddfb56a1ac7f
|
||||
zh: c1762158e49a4851
|
||||
/deepseek-ai-dsh-headless/use-this-package/credentials:
|
||||
en: ca8217837ee9c698
|
||||
zh: fa20daf27d4e09ef
|
||||
en: bf02dd1c6888da25
|
||||
zh: 83dca9600cd7e8ff
|
||||
/deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity:
|
||||
en: 85120c2d6db2cac4
|
||||
zh: bac532df6c84e755
|
||||
|
||||
@@ -55,6 +55,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
The shipped headless profile disables the managed credential store and its authorization and DeepSeek account consumers. API-key routes use the launch environment, then `<invocation cwd>/.env`, then `$DSH_HOME/.env`. Keys and sign-ins saved by the GUI are ignored without changing their file. Providers that require a saved sign-in fail unless stored authentication is restored; external provider credential chains, such as AWS profiles, remain available.
|
||||
|
||||
The DeepSeek Search and Fetch APIs [follow the session's DeepSeek credentials](../../web/web-deepseek-api/README.md#authentication). In the shipped headless profile, they send the DeepSeek model route's `DEEPSEEK_API_KEY` to that route's origin, so no sign-in is needed. Without that key, the model request omits `web_search`.
|
||||
|
||||
Unset an empty launch variable to allow its `.env` fallback; an exported empty value shadows that fallback.
|
||||
|
||||
To restore stored authentication, add these rows to `$DSH_HOME/profiles/headless/cordis.patch.yml` or a `--patch` overlay:
|
||||
|
||||
@@ -55,6 +55,8 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
|
||||
|
||||
随附的 headless profile 禁用托管凭据存储,以及依赖它的 authorization 和 DeepSeek account 消费方。API 密钥路由依次使用启动环境、`<运行命令的目录>/.env` 和 `$DSH_HOME/.env`。GUI 保存的密钥和登录凭据不会被使用,存储文件保持原样。需要已保存登录凭据的提供方会失败,除非恢复存储认证;AWS profile 等提供方外部凭据链仍然可用。
|
||||
|
||||
DeepSeek Search 与 Fetch API [跟随会话的 DeepSeek 凭据](../../web/web-deepseek-api/README.zh.md#authentication)。在随附的 headless profile 中,它们将 DeepSeek 模型路由的 `DEEPSEEK_API_KEY` 发往该路由的 origin,因此无需登录。没有该密钥时,模型请求不包含 `web_search`。
|
||||
|
||||
启动变量为空时,请取消导出该变量,让 `.env` 后备生效;已导出的空值会遮蔽后备值。
|
||||
|
||||
要恢复存储认证,请把以下行加入 `$DSH_HOME/profiles/headless/cordis.patch.yml` 或 `--patch` 覆盖文件:
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/client-web-gui-browser-half/packages:
|
||||
en: d7adae6baeed495b
|
||||
zh: 9e49fb00adc8eec1
|
||||
en: 2165938f26300774
|
||||
zh: 5f739534a8509545
|
||||
/client-web-gui-browser-half/related-documentation:
|
||||
en: d1d506e68fab7333
|
||||
zh: 1a90fe6f3f8f24a9
|
||||
|
||||
@@ -81,7 +81,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
|
||||
| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.md) | Provides the agent-loop settings page on the Plugins page | — |
|
||||
| [`ui-settings-session-log/`](ui-settings-session-log/README.md) | Controls API Session-log upload in General settings | — |
|
||||
| [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — |
|
||||
| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — |
|
||||
| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web search and fetch settings page on the Plugins page | — |
|
||||
| [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — |
|
||||
| [`ui-entry-customization/`](ui-entry-customization/README.md) | Adds region-local More menus that pin or unpin plugin launchers and start a plugin draft | — |
|
||||
| [`ui-plugin-market/`](ui-plugin-market/README.md) | Contributes the Discover tab to the Plugins page: browse market indexes and install their bundles | — |
|
||||
|
||||
@@ -81,7 +81,7 @@ kind: "package-group"
|
||||
| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — |
|
||||
| [`ui-settings-session-log/`](ui-settings-session-log/README.zh.md) | 在通用设置中控制随 API 上传会话日志 | — |
|
||||
| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供子智能体设置页 | — |
|
||||
| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — |
|
||||
| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索与获取设置页 | — |
|
||||
| [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — |
|
||||
| [`ui-entry-customization/`](ui-entry-customization/README.zh.md) | 添加分区域的“更多”菜单,固定或收起插件入口,并开始创建插件的草稿 | — |
|
||||
| [`ui-plugin-market/`](ui-plugin-market/README.zh.md) | 为“插件”页面贡献“发现”标签页:浏览市场索引并安装其中的组合包 | — |
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: 5a06147f2a79eccf
|
||||
zh: 6ca5899680e3cc6f
|
||||
en: 01ba764b51f010e8
|
||||
zh: e43a158a5b09d987
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: cd4e17d62c407541
|
||||
zh: 9610d3ee55c0e56a
|
||||
|
||||
@@ -31,7 +31,7 @@ Below 768px, the page uses 20px horizontal insets to align with the conversation
|
||||
|
||||
[Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent. LiveShare declares the same default-hidden intent in its bundle manifest (`dsh.bundle.listedByDefault: false`), so it stays out of both lists, including **More**, until a declared row runs or it is enabled. The page's manifest rule is generic: a bundle whose manifest does not declare `listedByDefault: false` is listed; one that does is listed only when the profile enables it, the installation carries it selected, or one of its declared rows runs; a hidden bundle is not opened by its ID either.
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search and fetch, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
[Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。LiveShare 在其 bundle manifest 中声明相同的默认隐藏意图(`dsh.bundle.listedByDefault: false`),因此在自身声明的行开始运行或被启用前,它不会出现在两个列表(含**更多**页)中。页面的 manifest 规则是通用的:未声明 `listedByDefault: false` 的组合包会列出;声明了的,只有在 profile 启用、安装随附版本被选中,或它声明的某一行开始运行时才列出;隐藏的组合包也不能通过 ID 打开详情。
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索与获取、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
|
||||
|
||||
|
||||
@@ -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: 88147277504a84c5
|
||||
zh: 7262e02458f31515
|
||||
en: 88b8ad7d3580e897
|
||||
zh: 36380f07046212c5
|
||||
/deepseek-ai-dsh-client-ui-settings-web-search:
|
||||
en: 968c0f6ba2415331
|
||||
zh: aaf9e2aa22eafc98
|
||||
/deepseek-ai-dsh-client-ui-settings-web-search/summary:
|
||||
en: f0f8d6a8f0f74de3
|
||||
zh: 1926ea7e173eea51
|
||||
en: 93af84c75a57345b
|
||||
zh: 67836fe89e423a52
|
||||
/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: ba0053e60d4a5e9e
|
||||
zh: 1e79aad162155e9c
|
||||
en: bf26f223253bb9b8
|
||||
zh: b687ab66557c1f95
|
||||
/deepseek-ai-dsh-client-ui-settings-web-search/understand-the-implementation:
|
||||
en: d200933a35bf4972
|
||||
zh: 2484eb9635102357
|
||||
en: 078184ee0b31389a
|
||||
zh: 8d74eb3b687a2d6f
|
||||
/deepseek-ai-dsh-client-ui-settings-web-search/further-exploration:
|
||||
en: 72585c993e443510
|
||||
zh: 90cf6894ccfe8588
|
||||
en: 782f6f99769f5645
|
||||
zh: 310a9544ae051c9d
|
||||
/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: 2f7f093f506401cd
|
||||
zh: a9b748ae7d4c64e4
|
||||
en: 0024baf967a2913b
|
||||
zh: c6bd2f9eb52d83e3
|
||||
/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 DeepSeek web-search settings page on the dsh web client's Plugins page: the Search and Fetch API switch, the API key, endpoint, and per-search limit."
|
||||
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."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Open **Plugins** in the sidebar and select **Web search** in the Official group to switch between DeepSeek Messages search and the DeepSeek Search and Fetch APIs, and to set the selected API's key, endpoint, and per-search limit. The page stages what is typed and writes it only on save; the key is written through the credentials domain rather than the settings document, so its literal never rides a response. The page exists while the Host serves the `web-search-deepseek` 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 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.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,9 +25,15 @@ Open **Plugins** in the sidebar and select **Web search** in the Official group
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; without one, it says that only conversations using a DeepSeek Account model can search, through the default endpoint, because those searches authenticate with the account sign-in; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. The API base URL and the active limit, **Max searches per request** for Messages or **Results per search** for the Search API, render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts.
|
||||
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.
|
||||
|
||||
**Use the new Web Search API** defaults off, so search keeps using Messages and fetch keeps using local HTTP. Its hint links to the DeepSeek search and fetch guide for the new API's features and billing. Saving it on selects the DeepSeek Search and Fetch APIs together; saving it off clears both selections back to the deployment's defaults, or stores Messages search with local HTTP fetch when the deployment composes the APIs. The switch appears only while the Host also serves the `web` and `web-deepseek-api` namespaces. With the switch on, the page shows **Results per search** (an integer from 1 through 50, default 10) in place of the Messages-only **Max searches per request**, and invalid result counts block saving. The switch is disabled while an invalid edit or a failed save is staged, so a field it would hide stays visible. Each mode keeps its own API base when switching. The **Messages API base URL** and **Search / Fetch API base URL** labels identify the active protocol; their hints name the required prefix and appended operation path. One save writes the provider settings before the selection, so a refused section leaves the switch unsaved. See [web-search-deepseek](../../web/web-search-deepseek/README.md#use-this-package) and [web-deepseek-api](../../web/web-deepseek-api/README.md#use-this-package) for API behavior.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
Value fields render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied; a value equal to the deployment default saves as an override. The provider menus are disabled while an invalid edit or a failed save is staged, so a field the change would hide stays visible.
|
||||
|
||||
A staged edit to a hidden provider's field is kept and saved. One save writes the provider settings before the selection, so a refused section leaves the selection unsaved. Nothing is written until **Save**; leaving the page drops the drafts. See [web-deepseek-api](../../web/web-deepseek-api/README.md#use-this-package) for API behavior.
|
||||
|
||||
-----
|
||||
|
||||
@@ -37,7 +43,7 @@ The **Web search** card in the Official group opens the page. **API key** starts
|
||||
<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 switch stages `searchProvider` and `fetchProvider` in `web`; a target equal to the composed value stages a reset instead. The key is the selection form's one secret control: its write goes to `remote.credentials.set` under the reference the selected API section's `apiKeyEnv` names (`DEEPSEEK_API_KEY` when it names none), and success is read back from `remote.credentials.describe`. 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. Reference changes clear the previous 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 for the selected reference updates the card; a save uses its own readback result. Disposal stops queued reads and suppresses late answers. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.configForms.whileServed`.
|
||||
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])`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -49,10 +55,9 @@ The Host half is an empty `apply`, present only so the package holds a Loader ro
|
||||
- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into.
|
||||
- [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 key writes through.
|
||||
- [web-search-deepseek](../../web/web-search-deepseek/README.md) — the Messages search provider whose namespace keeps the page served.
|
||||
- [web-deepseek-api](../../web/web-deepseek-api/README.md) — the Search and Fetch API providers the switch selects.
|
||||
- [web](../../web/web/README.md) — the provider selection the switch writes.
|
||||
- [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](../../web/web/README.md) — the provider selection and provider list the menus use.
|
||||
|
||||
-----
|
||||
|
||||
@@ -69,7 +74,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Three fields of the namespace** — the provider's model, API version, and token budget stay at their composed values; the page edits the key, the endpoint, and the search budget only.
|
||||
- **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`.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "dsh Web 客户端插件页上的 DeepSeek 网页搜索设置页:Search 与 Fetch API 开关、API Key、接口地址与每次搜索的上限。"
|
||||
description: "dsh Web 客户端插件页上的网页搜索与获取设置页:Search 与 Fetch 提供方下拉框、DeepSeek Search API 的结果数,以及旧版 Messages 搜索的设置。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在侧栏打开**插件**,在官方分组里选择**网页搜索**,即可在 DeepSeek Messages 搜索与 DeepSeek Search 与 Fetch API 之间切换,并设置所选 API 的密钥、接口地址和每次搜索的上限。页面暂存输入、只在保存时写入;密钥通过凭据域写入而不进设置文件,其明文从不出现在任何响应里。页面只在 Host 服务 `web-search-deepseek` 命名空间期间存在。
|
||||
在侧栏打开**插件**,在官方分组里选择**网页搜索与获取**,即可选择网页搜索和网页获取使用的提供方、设置 Search API 的结果数,或配置旧版 Messages 搜索。DeepSeek Search 与 Fetch API 在这里没有凭据设置:它们使用会话的 DeepSeek 账号或 DeepSeek API Key。页面只在保存时写入;旧版搜索的密钥经凭据域写入。页面在 Host 服务 `web` 命名空间期间存在;各官方提供方的控件只在 Host 服务该提供方命名空间时显示。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,9 +25,15 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;未配置时提示仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索,因为这类搜索使用账号登录鉴权;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。API 基础地址和当前模式的上限(Messages 的**单次请求最多搜索次数**或 Search API 的**每次搜索结果数**)显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。
|
||||
官方分组里的**网页搜索与获取**卡片打开这一页。页面分为**网页搜索提供方**与**网页获取提供方**两节,每节标题下方紧接一个提供方下拉框。下拉框列出 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。插件提供方所在的节在其说明之后提示到该插件自己的设置中配置。
|
||||
|
||||
**启用新版 Web 搜索 API** 默认关闭,搜索继续使用 Messages,抓取继续使用本地 HTTP。提示中链接到 DeepSeek 网页搜索与抓取指南,说明新版 API 的功能与计费。开启并保存后同时选择 DeepSeek Search 与 Fetch API;关闭并保存后两项选择都恢复为部署默认值,部署默认已组合这两个 API 时则写入 Messages 搜索和本地 HTTP 抓取。只有 Host 同时提供 `web` 和 `web-deepseek-api` 命名空间时才显示该开关。开关开启时以**每次搜索结果数**(1–50 的整数,默认 10)代替仅适用于 Messages 的**单次请求最多搜索次数**,无效的结果条数会阻止保存。暂存了无效编辑或保存失败时开关不可用,以免它隐藏的字段不再可见。切换时分别保留两种模式的 API 基础地址。**Messages API 基础地址**与 **Search / Fetch API 基础地址**标签标明当前协议,提示说明应包含的前缀和程序追加的操作路径。一次保存先写入提供方设置再写入选择,因此某一节被拒绝时开关不会保存。API 行为见 [web-search-deepseek](../../web/web-search-deepseek/README.zh.md#use-this-package) 和 [web-deepseek-api](../../web/web-deepseek-api/README.zh.md#use-this-package)。
|
||||
**网页搜索提供方**一节中,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(旧版)**在同样的面板布局中显示 API Key、独立的 Messages Base URL 与**单次请求最多搜索次数**(`maxUses`,正整数,默认 5)。密钥写入 `web-search-deepseek.apiKeyEnv` 指向的引用(默认 `DEEPSEEK_API_KEY`);端点和次数上限写入 `web-search-deepseek`,与 API 设置分开。切换其他提供方会丢弃未保存的密钥,但保留端点和次数设置。
|
||||
|
||||
数值字段显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置;填写部署默认值则作为覆盖值保存。暂存了无效编辑或保存失败时提供方下拉框不可用,以免切换后隐藏的字段不再可见。
|
||||
|
||||
对已隐藏提供方字段的暂存编辑会被保留并保存。一次保存先写入提供方设置再写入选择,因此某一节被拒绝时选择不会保存。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。API 行为见 [web-deepseek-api](../../web/web-deepseek-api/README.zh.md#use-this-package)。
|
||||
|
||||
-----
|
||||
|
||||
@@ -37,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`;目标值等于组合值时改为暂存重置。密钥是选择表单里唯一的密文控件:写入走 `remote.credentials.set`,引用名取自当前所选 API 设置节的 `apiKeyEnv`(未指定时为 `DEEPSEEK_API_KEY`),是否成功由 `remote.credentials.describe` 回读判定。控制器把同步发布的 scope 更新和引用切换合并成下一个微任务中的一次凭据读取;连接重置后的 scope 刷新也会重新同步凭据。引用变化会立即清除旧凭据状态。Host 对所监视引用发出 `credentials/reference-updated` 事件以及写入时,都会独立触发读取,即使此前的读取仍未完成。只有所选引用的最新读取会更新卡片;保存使用自身的回读结果。释放控制器后不再执行排队的读取,也不再发布迟到的响应。页面通过 `ctx.configForms.whileServed` 把 `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`。选择表单的密文控件为 `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>
|
||||
|
||||
@@ -49,10 +55,9 @@ kind: "package-reference"
|
||||
- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。
|
||||
- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。
|
||||
- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。
|
||||
- [credentials](../../credentials/README.zh.md)——密钥写入所经的凭据引用 seam。
|
||||
- [web-search-deepseek](../../web/web-search-deepseek/README.zh.md)——其命名空间决定页面是否出现的 Messages 搜索提供方。
|
||||
- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——开关选择的 Search 与 Fetch API 提供方。
|
||||
- [web](../../web/web/README.zh.md)——开关写入的提供方选择。
|
||||
- [credentials](../../credentials/README.zh.md)——旧版搜索密钥写入所经的凭据引用 seam。
|
||||
- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——Search 一节编辑其结果数的 Search 与 Fetch API 提供方。
|
||||
- [web](../../web/web/README.zh.md)——下拉框使用的提供方选择与提供方列表。
|
||||
|
||||
-----
|
||||
|
||||
@@ -69,7 +74,7 @@ kind: "package-reference"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **只编辑命名空间的三个字段**——提供方的模型、API 版本和 token 预算保持组合值不变;本页只编辑密钥、接口地址和搜索次数。
|
||||
- **自动选择只看部署组合层**——组合层没有为该能力设置提供方时,下拉框才提供自动选择。自动选择生效时,Host 的 `DSH_WEB_SEARCH_PROVIDER` 或 `DSH_WEB_FETCH_PROVIDER` 环境变量会改为由它指定提供方,而页面不会显示这一选择。两者都没有时,如果注册了多个可用提供方,自动选择会让每次操作以 `WEB_PROVIDER_AMBIGUOUS` 失败。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Einstellungen für die Websuche",
|
||||
"description": "Konfigurieren Sie den Schlüssel, die Dienstadresse und das Anforderungsbudget für die DeepSeek-Websuche."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Configuración de búsqueda web",
|
||||
"description": "Configure la clave, la dirección del servicio y solicite el presupuesto para la búsqueda web DeepSeek."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Paramètres de recherche sur le Web",
|
||||
"description": "Configurez la clé, l'adresse du service et le budget de demande pour la recherche sur le Web DeepSeek."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Web 検索設定",
|
||||
"description": "DeepSeek Web 検索のキー、サービスアドレス、リクエスト予算を設定します。"
|
||||
"title": "Web 検索と取得の設定",
|
||||
"description": "Web 検索と Web 取得のプロバイダーを選択し、DeepSeek プロバイダーのキー、サービスアドレス、上限を設定します。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Configurações de pesquisa na web",
|
||||
"description": "Configure a chave, o endereço do serviço e solicite o orçamento para DeepSeek pesquisa na web."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Настройки веб-поиска",
|
||||
"description": "Настройте ключ, адрес службы и бюджет запроса для веб-поиска DeepSeek."
|
||||
"title": "Настройки веб-поиска и загрузки",
|
||||
"description": "Выберите поставщиков веб-поиска и загрузки и настройте ключ, адрес службы и ограничения поставщиков DeepSeek."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "网页搜索设置",
|
||||
"description": "配置 DeepSeek 网页搜索的密钥、服务地址和请求预算。"
|
||||
"title": "网页搜索与获取设置",
|
||||
"description": "选择网页搜索与网页获取的提供方,并配置 DeepSeek 提供方的密钥、服务地址和上限。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-settings-web-search",
|
||||
"description": "Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget",
|
||||
"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",
|
||||
"version": "0.2.2-alpha.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/dsh-web": "workspace:*",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,16 +1,89 @@
|
||||
.feature {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
.section + .section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.selectorLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The gray panel holding a section's settings below its provider menu. */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.hint {
|
||||
@@ -20,6 +93,16 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--dsw-alias-link);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,26 +1,53 @@
|
||||
/**
|
||||
* The web-search settings page: the switch between Messages search and the
|
||||
* Search and Fetch APIs, the selected API's endpoint and per-search limit, and
|
||||
* the key — which
|
||||
* is written through the credentials domain, never into the settings section,
|
||||
* so the literal never rides a response.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useRef, useState, type ReactNode } from 'react'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
||||
import { SettingsForm, SettingsSecretField, SettingsValueField, Switch } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
Button, IconChevronDownOutlineRegular, Menu, SettingsForm, SettingsSecretField, SettingsValueField,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { formLabels } from './locales.ts'
|
||||
import { formLabels, type WebSearchSettingsLocaleKey } from './locales.ts'
|
||||
import styles from './WebSearchCard.module.css'
|
||||
import type { WebSearchCardFace } from './web-search-card-controller.ts'
|
||||
import {
|
||||
PROVIDER_IDS, type WebProviderOption, type WebProviderSelection, type WebSearchCardFace, type WebSearchCardState,
|
||||
} from './web-search-card-controller.ts'
|
||||
|
||||
/** Props the renderer binds for the web-search page. */
|
||||
/** Props the renderer binds for the web search and fetch page. */
|
||||
export type WebSearchCardProps =
|
||||
PropsRuntime<'plugins.item'>
|
||||
& PropsLocale<'settings.webSearch'>
|
||||
& InjectFace<WebSearchCardFace>
|
||||
|
||||
type T = WebSearchCardProps['t']
|
||||
|
||||
/** Dictionary names of the official providers; any other provider shows its registered title or id. */
|
||||
const PROVIDER_LABELS: Record<string, WebSearchSettingsLocaleKey> = {
|
||||
[PROVIDER_IDS.apiSearch]: 'providerDeepSeek',
|
||||
[PROVIDER_IDS.legacySearch]: 'providerLegacy',
|
||||
[PROVIDER_IDS.localFetch]: 'providerLocal',
|
||||
[PROVIDER_IDS.apiFetch]: 'providerDeepSeekFetch',
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the web-search provider's one-liner or its settings form, as the Plugins page asks.
|
||||
* The menu label of one provider.
|
||||
* @param t - the page's locale reader.
|
||||
* @param option - the provider entry.
|
||||
* @returns the localized name, marked when the Host does not register it.
|
||||
*/
|
||||
function providerLabel(t: T, option: WebProviderOption): string {
|
||||
const key = PROVIDER_LABELS[option.id]
|
||||
const name = key === undefined ? option.title ?? option.id : t(key)
|
||||
return option.missing ? t('providerMissing', { id: name }) : name
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page's one-liner or its settings form, as the Plugins page asks.
|
||||
* @param props - the view asked for, locale copy, the form snapshot, and its actions.
|
||||
* @returns the one-liner, or the form.
|
||||
*/
|
||||
@@ -29,62 +56,174 @@ export function WebSearchCard(props: WebSearchCardProps) {
|
||||
const state = props.useWebSearchCard(snapshot => snapshot)
|
||||
if (props.view === 'summary') return t('description')
|
||||
const disabled = !state.writable || state.saving
|
||||
const searchApi = state.useSearchApi
|
||||
const limitField = searchApi ? 'count' : 'maxUses'
|
||||
const menuDisabled = disabled || state.selectionBlocked
|
||||
const { search, fetch } = state
|
||||
// The selected provider's own description, in the active language; an empty translation shows nothing.
|
||||
const describe = (section: WebSearchCardState['search' | 'fetch']): string | undefined => {
|
||||
const description = section.options.find(option => option.id === section.selected)?.description
|
||||
return description === undefined ? undefined : props.resolveText(description) || undefined
|
||||
}
|
||||
return (
|
||||
<SettingsForm labels={formLabels(t)} state={state} onSave={props.save} onDiscard={props.discard}>
|
||||
{state.apiSwitchAvailable && (
|
||||
<div className={styles.feature}>
|
||||
<label className={styles.toggleRow}>
|
||||
<span>{t('useSearchApi')}</span>
|
||||
<Switch checked={searchApi} label={t('useSearchApi')} disabled={disabled || state.apiSwitchBlocked}
|
||||
onChange={(checked) => { props.setSearchApi(checked) }} />
|
||||
</label>
|
||||
<p className={styles.hint}>
|
||||
{t('useSearchApiHint')}
|
||||
<a className={styles.link} href={t('searchGuideUrl')} target="_blank" rel="noopener noreferrer">{t('searchGuide')}</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SettingsSecretField
|
||||
id="plugin-config-web-search-key"
|
||||
label={t('apiKey')}
|
||||
hint={t('apiKeyHint')}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals.
|
||||
// Its own writability is what disables this control — a key sourced
|
||||
// from the process environment cannot be written from here.
|
||||
disabled={!state.apiKeyWritable}
|
||||
text={state.apiKey.text}
|
||||
configured={state.apiKeyConfigured}
|
||||
stateLabel={state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset')}
|
||||
onEdit={(text) => { props.edit('apiKey', text) }}
|
||||
/>
|
||||
<SettingsValueField
|
||||
id="plugin-config-web-search-endpoint"
|
||||
label={t(searchApi ? 'searchApiBaseUrl' : 'baseUrl')}
|
||||
hint={t(searchApi ? 'searchApiBaseUrlHint' : 'baseUrlHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalidNumber')}
|
||||
disabled={disabled}
|
||||
{...state.baseURL}
|
||||
onEdit={(text) => { props.edit('baseURL', text) }}
|
||||
onReset={() => { props.resetField('baseURL') }}
|
||||
/>
|
||||
<SettingsValueField
|
||||
id={searchApi ? 'plugin-config-web-search-count' : 'plugin-config-web-search-max-uses'}
|
||||
label={t(limitField)}
|
||||
hint={t(searchApi ? 'countHint' : 'maxUsesHint')}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t(searchApi ? 'invalidCount' : 'invalidNumber')}
|
||||
numeric
|
||||
disabled={disabled}
|
||||
{...state[limitField]}
|
||||
onEdit={(text) => { props.edit(limitField, text) }}
|
||||
onReset={() => { props.resetField(limitField) }}
|
||||
/>
|
||||
<section className={styles.section} aria-labelledby="plugin-config-web-search-section">
|
||||
<h4 id="plugin-config-web-search-section" className={styles.sectionTitle}>{t('searchSection')}</h4>
|
||||
<ProviderSelect id="plugin-config-web-search-provider" labelledBy="plugin-config-web-search-section" t={t}
|
||||
section={search} disabled={menuDisabled} field="searchProvider" onSelect={props.selectProvider}
|
||||
providersFailed={state.providersFailed} onRetry={props.retryProviders}
|
||||
description={describe(search)}
|
||||
hint={search.kind === 'deepseek'
|
||||
? <a className={styles.link} href={t('searchGuideUrl')} target="_blank" rel="noopener noreferrer">{t('searchGuide')}</a>
|
||||
: search.kind === 'plugin' ? t('pluginProviderHint') : undefined} />
|
||||
{search.kind === 'deepseek' && (
|
||||
<div className={styles.panel}>
|
||||
<SettingsValueField id="plugin-config-web-search-count" label={t('count')} hint={t('countHint')} numeric
|
||||
overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('invalidCount')} disabled={disabled}
|
||||
{...state.count}
|
||||
onEdit={(text) => { props.edit('count', text) }} onReset={() => { props.resetField('count') }} />
|
||||
</div>
|
||||
)}
|
||||
{search.kind === 'legacy' && state.legacyKey !== undefined && (
|
||||
<div className={styles.panel}>
|
||||
<SettingsSecretField id="plugin-config-web-search-key" label={t('credential')}
|
||||
hint={t('legacyKeyHint', { ref: state.legacyKey.ref })}
|
||||
// The credentials domain accepts a key even when the settings document
|
||||
// itself is read-only; they are separate stores with separate refusals,
|
||||
// so the settings document's writability does not disable this control.
|
||||
// The key's own writability does (a key sourced from the process
|
||||
// environment cannot be written from here), and so does a save in
|
||||
// flight, which would discard an edit made before it settles.
|
||||
disabled={!state.legacyKey.writable || state.saving} text={state.legacyKey.text}
|
||||
configured={state.legacyKey.configured}
|
||||
stateLabel={state.legacyKey.configured ? t('apiKeySet') : t('apiKeyUnsetApi')}
|
||||
onEdit={(text) => { props.edit('legacyApiKey', text) }} />
|
||||
<SettingsValueField id="plugin-config-web-search-endpoint" label={t('baseUrl')} hint={t('baseUrlHint', { path: '/messages' })}
|
||||
overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('invalidNumber')} disabled={disabled}
|
||||
{...state.messagesBaseURL}
|
||||
onEdit={(text) => { props.edit('messagesBaseURL', text) }} onReset={() => { props.resetField('messagesBaseURL') }} />
|
||||
<SettingsValueField id="plugin-config-web-search-max-uses" label={t('maxUses')} hint={t('maxUsesHint')} numeric
|
||||
overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('invalidMaxUses')} disabled={disabled}
|
||||
{...state.maxUses}
|
||||
onEdit={(text) => { props.edit('maxUses', text) }} onReset={() => { props.resetField('maxUses') }} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.section} aria-labelledby="plugin-config-web-fetch-section">
|
||||
<h4 id="plugin-config-web-fetch-section" className={styles.sectionTitle}>{t('fetchSection')}</h4>
|
||||
<ProviderSelect id="plugin-config-web-fetch-provider" labelledBy="plugin-config-web-fetch-section" t={t}
|
||||
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} />
|
||||
</section>
|
||||
</SettingsForm>
|
||||
)
|
||||
}
|
||||
|
||||
/** Menu id of the automatic-selection entry, matching a selection that names no provider. */
|
||||
const AUTO_ID = ''
|
||||
|
||||
/**
|
||||
* A menu button that shows its current value and opens a list of choices.
|
||||
* Its accessible name is the element `labelledBy` names followed by the current
|
||||
* value, so the label does not replace the value.
|
||||
* @param props - the control id, the id of the element naming it, the entries, the current entry and its
|
||||
* label, the lock state, and the selection callback.
|
||||
* @returns the anchored menu.
|
||||
*/
|
||||
function Dropdown(props: {
|
||||
id: string
|
||||
/** Id of the element whose text names the menu. */
|
||||
labelledBy: string
|
||||
items: readonly { id: string; label: string }[]
|
||||
selectedId: string
|
||||
/** The label the button shows for the current value. */
|
||||
valueLabel: string
|
||||
disabled: boolean
|
||||
/** Called with a newly chosen entry; choosing the current one calls nothing. */
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
// A disabled menu stays closed even if it was open when the lock arrived.
|
||||
const shown = open && !props.disabled
|
||||
const valueId = `${props.id}-value`
|
||||
const anchor = (
|
||||
<button ref={trigger} id={props.id} type="button" className={styles.selector}
|
||||
aria-haspopup="menu" aria-expanded={shown} aria-labelledby={`${props.labelledBy} ${valueId}`} disabled={props.disabled}
|
||||
onClick={() => { setOpen(value => !value) }}>
|
||||
<span id={valueId} className={styles.selectorLabel}>{props.valueLabel}</span>
|
||||
<IconChevronDownOutlineRegular className={styles.chevron} />
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<Menu
|
||||
open={shown}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={props.items}
|
||||
selectedId={props.selectedId}
|
||||
onSelect={(id) => {
|
||||
trigger.current?.focus({ preventScroll: true })
|
||||
setOpen(false)
|
||||
if (id !== props.selectedId) props.onSelect(id)
|
||||
}}
|
||||
portal
|
||||
autoFocus
|
||||
minWidthFromAnchor
|
||||
anchor={anchor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice that a list could not be read, with a retry.
|
||||
* @param props - the notice copy, the retry label, and the retry callback.
|
||||
* @returns the status line.
|
||||
*/
|
||||
function ReadFailure(props: { message: string; retry: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className={styles.notice} role="status">
|
||||
<span>{props.message}</span>
|
||||
<Button variant="outline" size="sm" onClick={props.onRetry}>{props.retry}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One section's provider menu, with the selected provider's description and
|
||||
* the page's own line about it below, and a notice with a retry while the provider list could not be read.
|
||||
* @param props - the control id, the id of the section title naming it, the section state, the selection callback, and the list read state.
|
||||
* @returns the labelled menu.
|
||||
*/
|
||||
function ProviderSelect(props: {
|
||||
id: string
|
||||
/** Id of the section title, which names the menu. */
|
||||
labelledBy: string
|
||||
t: T
|
||||
section: WebSearchCardState['search' | 'fetch']
|
||||
disabled: boolean
|
||||
field: keyof WebProviderSelection
|
||||
onSelect: WebSearchCardFace['selectProvider']
|
||||
/** The selected provider's registered description, resolved to the active language, if it has one. */
|
||||
description: string | undefined
|
||||
/** The page's own line about the selected provider, after its description, if it has one. */
|
||||
hint: ReactNode
|
||||
/** Whether the latest provider list read failed. */
|
||||
providersFailed: boolean
|
||||
onRetry: WebSearchCardFace['retryProviders']
|
||||
}) {
|
||||
const { t, section } = props
|
||||
const current = section.options.find(option => option.id === section.selected)
|
||||
const items = [
|
||||
...section.auto ? [{ id: AUTO_ID, label: t('providerAuto') }] : [],
|
||||
...section.options.map(option => ({ id: option.id, label: providerLabel(t, option) })),
|
||||
]
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Dropdown id={props.id} labelledBy={props.labelledBy} items={items} selectedId={section.selected}
|
||||
valueLabel={current === undefined ? t('providerAuto') : providerLabel(t, current)} disabled={props.disabled}
|
||||
onSelect={(id) => { props.onSelect(props.field, id) }} />
|
||||
{props.description !== undefined && <p className={styles.hint}>{props.description}</p>}
|
||||
{props.hint !== undefined && <p className={styles.hint}>{props.hint}</p>}
|
||||
{props.providersFailed && <ReadFailure message={t('providersFailed')} retry={t('retry')} onRetry={props.onRetry} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* The web-search settings page, browser half: the switch between DeepSeek
|
||||
* Messages search and the Search and Fetch APIs, which writes the `web`
|
||||
* provider selection, and the selected API's key, endpoint, and per-search
|
||||
* limit over the `web-search-deepseek` or `web-deepseek-api` namespace. The
|
||||
* page registers into the Plugins page's `plugins.item` slot while the Host
|
||||
* serves `web-search-deepseek`, so a deployment without that provider shows no
|
||||
* trace of it; the switch appears only while the Host also serves `web` and
|
||||
* `web-deepseek-api`.
|
||||
* The web search and fetch settings page, browser half: one provider menu per
|
||||
* capability, which writes the `web` provider selection, the Search API result
|
||||
* count over the `web-deepseek-api` namespace, and legacy Messages search's
|
||||
* key, endpoint, and search limit over `web-search-deepseek`. The page
|
||||
* registers into the Plugins page's `plugins.item` slot while the Host serves
|
||||
* `web`; a provider's own controls appear only while the Host serves its
|
||||
* namespace.
|
||||
*/
|
||||
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -26,13 +25,13 @@ import { en, ja, es, fr, de, ru, pt, zh, type WebSearchSettingsLocaleKey } from
|
||||
|
||||
export type { WebSearchCardProps } from './WebSearchCard.tsx'
|
||||
export type {
|
||||
WebApiSettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState, WebSearchSettings,
|
||||
WebApiSettings, WebLegacySettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState,
|
||||
} from './web-search-card-controller.ts'
|
||||
export type { WebSearchSettingsLocaleKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Web-search settings page copy. */
|
||||
/** Web search and fetch settings page copy. */
|
||||
'settings.webSearch': WebSearchSettingsLocaleKey
|
||||
}
|
||||
}
|
||||
@@ -41,27 +40,36 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
export const NS = 'settings.webSearch'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms']
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms']
|
||||
|
||||
/**
|
||||
* Mount the web-search settings page while the Host serves its namespace.
|
||||
* Mount the web search and fetch settings page while the Host serves the `web` namespace.
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-settings-web-search: dictionaries')
|
||||
const card = new WebSearchCardController({
|
||||
web: ctx.configForms.get(WEB_NS), messages: ctx.configForms.get(WEB_SEARCH_NS), api: ctx.configForms.get(WEB_API_NS),
|
||||
web: ctx.configForms.get(WEB_NS), api: ctx.configForms.get(WEB_API_NS), legacy: ctx.configForms.get(WEB_SEARCH_NS),
|
||||
}, ctx)
|
||||
ctx.effect(() => () => { card.dispose() }, 'ui-settings-web-search: form subscription')
|
||||
// The credential the page reports is not part of any settings section, so
|
||||
// The credentials the page reports are not part of any settings section, so
|
||||
// no scope publishes when one is written. This is the only signal
|
||||
// that a key written on another surface reached the Host.
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }),
|
||||
'ui-settings-web-search: credential invalidations',
|
||||
)
|
||||
ctx.effect(() => ctx.configForms.whileServed([WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
||||
// Plugins register and retract providers without touching any settings
|
||||
// section, and a reconnected Host may hold a different set.
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.remote.$on('web/providers-changed', () => { card.refreshProviders() }),
|
||||
ctx.on('connection/reset', () => { card.refreshProviders() }),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-settings-web-search: provider invalidations')
|
||||
ctx.effect(() => ctx.configForms.whileServed([WEB_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
||||
name: 'plugins.item', id: 'web-search', order: 20, label: () => t('title'), locale: NS, inject: () => card.inject(),
|
||||
}, WebSearchCard))), 'ui-settings-web-search: page')
|
||||
}
|
||||
|
||||
@@ -4,30 +4,43 @@ import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Locale keys the page renders. */
|
||||
export type WebSearchSettingsLocaleKey =
|
||||
| 'title' | 'description' | 'useSearchApi' | 'useSearchApiHint' | 'searchGuide' | 'searchGuideUrl'
|
||||
| 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset'
|
||||
| 'baseUrl' | 'baseUrlHint' | 'searchApiBaseUrl' | 'searchApiBaseUrlHint' | 'maxUses' | 'maxUsesHint' | 'count' | 'countHint'
|
||||
| 'title' | 'description' | 'searchSection' | 'fetchSection'
|
||||
| 'providerDeepSeek' | 'providerLocal' | 'providerDeepSeekFetch' | 'providerMissing' | 'providerAuto'
|
||||
| 'providerLegacy' | 'legacyKeyHint' | 'maxUses' | 'maxUsesHint' | 'invalidMaxUses'
|
||||
| 'providersFailed' | 'retry'
|
||||
| 'pluginProviderHint' | 'searchGuide' | 'searchGuideUrl'
|
||||
| 'credential' | 'apiKeySet' | 'apiKeyUnsetApi'
|
||||
| 'baseUrl' | 'baseUrlHint'
|
||||
| 'count' | 'countHint'
|
||||
| 'overridden' | 'reset' | 'readOnly' | 'unavailable'
|
||||
| 'save' | 'saving' | 'saveFailed' | 'invalidNumber' | 'invalidCount'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<WebSearchSettingsLocaleKey, string> = {
|
||||
title: 'Web search',
|
||||
description: 'Set up the DeepSeek search provider.',
|
||||
useSearchApi: 'Use the new Web Search API',
|
||||
useSearchApiHint: 'Off by default: search runs through DeepSeek Messages. For the new Web Search API\'s features and pricing, see the ',
|
||||
title: 'Web search and fetch',
|
||||
description: 'Choose the providers web search and web fetch use.',
|
||||
searchSection: 'Web search provider',
|
||||
fetchSection: 'Web fetch provider',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (legacy)',
|
||||
legacyKeyHint: 'Uses the signed-in account on account-model requests; otherwise uses this API key ({ref}), shared with Models by default.',
|
||||
maxUses: 'Maximum searches per request',
|
||||
maxUsesHint: 'Maximum server-side searches per Messages request: a positive integer, default 5.',
|
||||
invalidMaxUses: 'Enter a positive integer, or leave blank to use the default.',
|
||||
providerLocal: 'Direct fetch',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (not loaded)',
|
||||
providerAuto: 'Automatic',
|
||||
providersFailed: "Couldn't load the provider list, so this menu may be incomplete.",
|
||||
retry: 'Retry',
|
||||
pluginProviderHint: 'Configure this provider in its own plugin settings.',
|
||||
credential: 'API key',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlHint: 'Include the API prefix. Leave blank to use the official address. The provider appends {path}.',
|
||||
apiKeyUnsetApi: 'No key is configured.',
|
||||
searchGuide: 'Search & Fetch guide',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'API key',
|
||||
apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||
apiKeySet: 'A key is configured.',
|
||||
apiKeyUnset: 'No key is configured; only conversations using a DeepSeek Account model can search, through the default endpoint.',
|
||||
baseUrl: 'Messages API base URL',
|
||||
baseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/anthropic/v1. The provider appends /messages. Leave blank to restore the default.',
|
||||
searchApiBaseUrl: 'Search / Fetch API base URL',
|
||||
searchApiBaseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/v1/tools. The provider appends /search or /fetch. Leave blank to restore the default.',
|
||||
maxUses: 'Max searches per request',
|
||||
maxUsesHint: 'How many searches the auxiliary model may run within one Messages request. Applies only to the Messages API.',
|
||||
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.',
|
||||
overridden: 'Overridden',
|
||||
@@ -43,22 +56,30 @@ export const en: Record<WebSearchSettingsLocaleKey, string> = {
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<WebSearchSettingsLocaleKey, string> = {
|
||||
title: '网页搜索',
|
||||
description: '设置 DeepSeek 的搜索提供方。',
|
||||
useSearchApi: '启用新版 Web 搜索 API',
|
||||
useSearchApiHint: '默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见',
|
||||
searchGuide: '网页搜索与抓取指南',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search',
|
||||
apiKey: 'API Key',
|
||||
apiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||
apiKeySet: '已配置密钥。',
|
||||
apiKeyUnset: '未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。',
|
||||
baseUrl: 'Messages API 基础地址',
|
||||
baseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/anthropic/v1;程序追加 /messages,留空恢复默认。',
|
||||
searchApiBaseUrl: 'Search / Fetch API 基础地址',
|
||||
searchApiBaseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。',
|
||||
title: '网页搜索与获取',
|
||||
description: '选择网页搜索与网页获取使用的提供方。',
|
||||
searchSection: '网页搜索提供方',
|
||||
fetchSection: '网页获取提供方',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek(旧版)',
|
||||
legacyKeyHint: '账号模型发起的请求使用已登录账号;其他请求使用此 API Key({ref}),默认与「模型」页共用',
|
||||
maxUses: '单次请求最多搜索次数',
|
||||
maxUsesHint: '辅助模型在一次 Messages 请求内最多可执行的搜索次数,仅适用于 Messages API。',
|
||||
maxUsesHint: '每次 Messages 请求允许的服务端搜索次数上限,须为正整数,默认 5',
|
||||
invalidMaxUses: '请填正整数;留空表示使用默认值',
|
||||
providerLocal: '本机直接获取',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id}(未加载)',
|
||||
providerAuto: '自动选择',
|
||||
providersFailed: '无法读取提供方列表,此菜单可能不完整',
|
||||
retry: '重试',
|
||||
pluginProviderHint: '请在该插件自己的设置中配置',
|
||||
credential: 'API Key',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlHint: '填写含 API 前缀的地址;留空时使用官方地址;程序追加 {path}',
|
||||
apiKeyUnsetApi: '未配置密钥。',
|
||||
searchGuide: '网页搜索与获取指南',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search',
|
||||
apiKeySet: '已配置密钥。',
|
||||
count: '每次搜索结果数',
|
||||
countHint: '每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。',
|
||||
overridden: '已覆盖',
|
||||
@@ -83,22 +104,30 @@ export function formLabels(t: (key: WebSearchSettingsLocaleKey) => string): Sett
|
||||
|
||||
/** Japanese copy, checked against the English keys. */
|
||||
export const ja = {
|
||||
'title': 'Web 検索',
|
||||
'description': 'DeepSeek 検索プロバイダーを設定します。',
|
||||
'useSearchApi': '新しい Web Search API を使用する',
|
||||
'useSearchApiHint': 'デフォルトではオフです。検索は DeepSeek Messages 経由で実行されます。新しい Web Search API の機能と料金については、',
|
||||
'title': 'Web 検索と取得',
|
||||
'description': 'Web 検索と Web 取得に使用するプロバイダーを選択します。',
|
||||
'searchSection': 'Web 検索プロバイダー',
|
||||
'fetchSection': 'Web 取得プロバイダー',
|
||||
'providerDeepSeek': 'DeepSeek Search API',
|
||||
'providerLegacy': 'DeepSeek(旧版)',
|
||||
'legacyKeyHint': 'アカウントモデルのリクエストではログイン中のアカウントを使い、それ以外ではこの API キー({ref}、既定ではモデル設定と共有)を使います。',
|
||||
'maxUses': 'リクエストごとの最大検索回数',
|
||||
'maxUsesHint': 'Messages リクエストごとのサーバー側検索回数の上限です。正の整数で、既定値は 5 です。',
|
||||
'invalidMaxUses': '正の整数を入力するか、既定値を使う場合は空欄にしてください。',
|
||||
'providerLocal': '直接取得',
|
||||
'providerDeepSeekFetch': 'DeepSeek Fetch API',
|
||||
'providerMissing': '{id}(未読み込み)',
|
||||
'providerAuto': '自動',
|
||||
'providersFailed': 'プロバイダー一覧を読み込めなかったため、このメニューは不完全な可能性があります。',
|
||||
'retry': '再試行',
|
||||
'pluginProviderHint': 'このプロバイダーは、そのプラグイン自身の設定で構成してください。',
|
||||
'credential': 'API キー',
|
||||
'baseUrl': 'ベース URL',
|
||||
'baseUrlHint': 'API プレフィックスを含めてください。空欄の場合は公式アドレスを使用します。プロバイダーは {path} を追加します。',
|
||||
'apiKeyUnsetApi': 'キーが設定されていません。',
|
||||
'searchGuide': '検索と取得のガイド',
|
||||
'searchGuideUrl': 'https://api-docs.deepseek.com/guides/search',
|
||||
'apiKey': 'API キー',
|
||||
'apiKeyHint': '設定ファイルの外に保存されます。現在のキーを維持するには空欄のままにしてください。',
|
||||
'apiKeySet': 'キーが設定されています。',
|
||||
'apiKeyUnset': 'キーが設定されていません。DeepSeek Account のモデルを使用する会話のみ、デフォルトのエンドポイント経由で検索できます。',
|
||||
'baseUrl': 'Messages API ベース URL',
|
||||
'baseUrlHint': 'https://api.deepseek.com/anthropic/v1 など、API プレフィックスを含めてください。プロバイダーは /messages を追加します。デフォルトに戻すには空欄にしてください。',
|
||||
'searchApiBaseUrl': 'Search / Fetch API ベース URL',
|
||||
'searchApiBaseUrlHint': 'https://api.deepseek.com/v1/tools など、API プレフィックスを含めてください。プロバイダーは /search または /fetch を追加します。デフォルトに戻すには空欄にしてください。',
|
||||
'maxUses': 'リクエストあたりの最大検索回数',
|
||||
'maxUsesHint': '1 回の Messages リクエスト内で補助モデルが実行できる検索回数です。Messages API にのみ適用されます。',
|
||||
'count': '検索あたりの結果数',
|
||||
'countHint': 'クエリごとに返される結果数: 1〜50 の整数、デフォルトは 10 です。これは結果数であり、検索呼び出し数ではありません。web_search は自身の結果上限を超えるソースを保持しません。',
|
||||
'overridden': '上書き済み',
|
||||
@@ -114,22 +143,30 @@ export const ja = {
|
||||
|
||||
/** Spanish dictionary, checked against the English keys. */
|
||||
export const es = {
|
||||
title: 'búsqueda web',
|
||||
description: 'Configure el proveedor de búsqueda DeepSeek.',
|
||||
useSearchApi: 'Utilice la nueva búsqueda web API',
|
||||
useSearchApiHint: 'Desactivado de forma predeterminada: la búsqueda se ejecuta a través de Mensajes DeepSeek. Para conocer las características y los precios del nuevo Web Search API, consulte el ',
|
||||
title: 'Búsqueda y obtención web',
|
||||
description: 'Elija los proveedores que usan la búsqueda web y la obtención web.',
|
||||
searchSection: 'Proveedor de búsqueda web',
|
||||
fetchSection: 'Proveedor de obtención web',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (heredado)',
|
||||
legacyKeyHint: 'Las solicitudes del modelo de cuenta usan la cuenta conectada; las demás usan esta clave API ({ref}), compartida con Modelos de forma predeterminada.',
|
||||
maxUses: 'Búsquedas máximas por solicitud',
|
||||
maxUsesHint: 'Máximo de búsquedas del servidor por solicitud Messages: entero positivo, predeterminado 5.',
|
||||
invalidMaxUses: 'Introduzca un entero positivo o deje vacío para usar el valor predeterminado.',
|
||||
providerLocal: 'Obtención directa',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (no cargado)',
|
||||
providerAuto: 'Automático',
|
||||
providersFailed: 'No se pudo cargar la lista de proveedores, por lo que este menú puede estar incompleto.',
|
||||
retry: 'Reintentar',
|
||||
pluginProviderHint: 'Configure este proveedor en la configuración de su propio complemento.',
|
||||
credential: 'Clave de API',
|
||||
baseUrl: 'URL base',
|
||||
baseUrlHint: 'Incluya el prefijo de la API. Déjelo en blanco para usar la dirección oficial. El proveedor agrega {path}.',
|
||||
apiKeyUnsetApi: 'No hay ninguna clave configurada.',
|
||||
searchGuide: 'Guía de búsqueda y recuperación',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'Clave de API',
|
||||
apiKeyHint: 'Almacenado fuera del archivo de configuración. Déjelo en blanco para conservar la clave actual.',
|
||||
apiKeySet: 'Se configura una clave.',
|
||||
apiKeyUnset: 'No hay ninguna clave configurada; solo se pueden buscar conversaciones que utilicen un modelo de cuenta DeepSeek, a través del punto final predeterminado.',
|
||||
baseUrl: 'Mensajes API URL base',
|
||||
baseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/anthropic/v1.. El proveedor agrega /messages. Déjelo en blanco para restaurar el valor predeterminado.',
|
||||
searchApiBaseUrl: 'Buscar/obtener URL base API',
|
||||
searchApiBaseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/v1/tools.. El proveedor agrega /search o /fetch. Déjelo en blanco para restaurar el valor predeterminado.',
|
||||
maxUses: 'Búsquedas máximas por solicitud',
|
||||
maxUsesHint: 'Cuántas búsquedas puede ejecutar el modelo auxiliar dentro de una solicitud de Mensajes. Aplica sólo para Mensajes API.',
|
||||
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.',
|
||||
overridden: 'Anulado',
|
||||
@@ -145,22 +182,30 @@ export const es = {
|
||||
|
||||
/** French dictionary, checked against the English keys. */
|
||||
export const fr = {
|
||||
title: 'Recherche sur le Web',
|
||||
description: 'Configurez le moteur de recherche DeepSeek.',
|
||||
useSearchApi: 'Utilisez la nouvelle recherche Web API',
|
||||
useSearchApiHint: "Désactivé par défaut : la recherche s'effectue dans les messages DeepSeek. Pour connaître les fonctionnalités et les tarifs du nouveau Web Search API, consultez le ",
|
||||
title: 'Recherche et récupération web',
|
||||
description: 'Choisissez les fournisseurs utilisés par la recherche web et la récupération web.',
|
||||
searchSection: 'Fournisseur de recherche web',
|
||||
fetchSection: 'Fournisseur de récupération web',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (ancienne version)',
|
||||
legacyKeyHint: 'Les requêtes du modèle de compte utilisent le compte connecté ; les autres utilisent cette clé API ({ref}), partagée avec Modèles par défaut.',
|
||||
maxUses: 'Recherches maximales par requête',
|
||||
maxUsesHint: 'Maximum de recherches côté serveur par requête Messages : entier positif, 5 par défaut.',
|
||||
invalidMaxUses: 'Saisissez un entier positif ou laissez vide pour utiliser la valeur par défaut.',
|
||||
providerLocal: 'Récupération directe',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (non chargé)',
|
||||
providerAuto: 'Automatique',
|
||||
providersFailed: 'Impossible de charger la liste des fournisseurs ; ce menu peut être incomplet.',
|
||||
retry: 'Réessayer',
|
||||
pluginProviderHint: 'Configurez ce fournisseur dans les paramètres de son propre plugin.',
|
||||
credential: 'Clé API',
|
||||
baseUrl: 'URL de base',
|
||||
baseUrlHint: "Incluez le préfixe de l'API. Laissez vide pour utiliser l'adresse officielle. Le fournisseur ajoute {path}.",
|
||||
apiKeyUnsetApi: "Aucune clé n'est configurée.",
|
||||
searchGuide: 'Guide de recherche et de récupération',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'Clé API',
|
||||
apiKeyHint: 'Stocké en dehors du fichier de paramètres. Laissez vide pour conserver la clé actuelle.',
|
||||
apiKeySet: 'Une clé est configurée.',
|
||||
apiKeyUnset: "Aucune clé n'est configurée ; seules les conversations utilisant un modèle de compte DeepSeek peuvent effectuer une recherche, via le point de terminaison par défaut.",
|
||||
baseUrl: 'URL de base des messages API',
|
||||
baseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/anthropic/v1.. Le fournisseur ajoute /messages. Laissez vide pour restaurer la valeur par défaut.',
|
||||
searchApiBaseUrl: "Rechercher/récupérer l'URL de base de API",
|
||||
searchApiBaseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/v1/tools.. Le fournisseur ajoute /search ou /fetch. Laissez vide pour restaurer la valeur par défaut.',
|
||||
maxUses: 'Nombre maximum de recherches par requête',
|
||||
maxUsesHint: "Combien de recherches le modèle auxiliaire peut exécuter dans une requête de messages. S'applique uniquement aux messages API.",
|
||||
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.',
|
||||
overridden: 'Remplacé',
|
||||
@@ -176,22 +221,30 @@ export const fr = {
|
||||
|
||||
/** German dictionary, checked against the English keys. */
|
||||
export const de = {
|
||||
title: 'Websuche',
|
||||
description: 'Richten Sie den Suchanbieter DeepSeek ein.',
|
||||
useSearchApi: 'Nutzen Sie die neue Websuche API',
|
||||
useSearchApiHint: 'Standardmäßig deaktiviert: Die Suche erfolgt über DeepSeek-Nachrichten. Informationen zu den Funktionen und Preisen der neuen Websuche API finden Sie im ',
|
||||
title: 'Websuche und Webabruf',
|
||||
description: 'Wählen Sie die Anbieter für Websuche und Webabruf.',
|
||||
searchSection: 'Anbieter für Websuche',
|
||||
fetchSection: 'Anbieter für Webabruf',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (Legacy)',
|
||||
legacyKeyHint: 'Kontomodellanfragen verwenden das angemeldete Konto; andere verwenden diesen API-Schlüssel ({ref}), standardmäßig mit Modelle geteilt.',
|
||||
maxUses: 'Maximale Suchen pro Anfrage',
|
||||
maxUsesHint: 'Maximale serverseitige Suchen pro Messages-Anfrage: positive Ganzzahl, Standard 5.',
|
||||
invalidMaxUses: 'Geben Sie eine positive Ganzzahl ein oder lassen Sie das Feld für den Standard leer.',
|
||||
providerLocal: 'Direkter Abruf',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (nicht geladen)',
|
||||
providerAuto: 'Automatisch',
|
||||
providersFailed: 'Die Anbieterliste konnte nicht geladen werden, daher ist dieses Menü möglicherweise unvollständig.',
|
||||
retry: 'Erneut versuchen',
|
||||
pluginProviderHint: 'Konfigurieren Sie diesen Anbieter in den Einstellungen seines eigenen Plugins.',
|
||||
credential: 'API-Schlüssel',
|
||||
baseUrl: 'Basis-URL',
|
||||
baseUrlHint: 'Fügen Sie das API-Präfix ein. Leer lassen, um die offizielle Adresse zu verwenden. Der Anbieter hängt {path} an.',
|
||||
apiKeyUnsetApi: 'Es ist kein Schlüssel konfiguriert.',
|
||||
searchGuide: 'Leitfaden zum Suchen und Abrufen',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'API-Schlüssel',
|
||||
apiKeyHint: 'Außerhalb der Einstellungsdatei gespeichert. Lassen Sie das Feld leer, um den aktuellen Schlüssel beizubehalten.',
|
||||
apiKeySet: 'Ein Schlüssel ist konfiguriert.',
|
||||
apiKeyUnset: 'Es ist kein Schlüssel konfiguriert; Nur Konversationen, die ein DeepSeek-Kontomodell verwenden, können über den Standardendpunkt suchen.',
|
||||
baseUrl: 'Nachrichten API Basis-URL',
|
||||
baseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/anthropic/v1.. Der Anbieter hängt /messages an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.',
|
||||
searchApiBaseUrl: 'Suchen/Abrufen der API-Basis-URL',
|
||||
searchApiBaseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/v1/tools.. Der Anbieter hängt /search oder /fetch an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.',
|
||||
maxUses: 'Maximale Anzahl an Suchanfragen pro Anfrage',
|
||||
maxUsesHint: 'Wie viele Suchvorgänge das Hilfsmodell innerhalb einer Nachrichtenanforderung ausführen darf. Gilt nur für die Nachrichten API.',
|
||||
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.',
|
||||
overridden: 'Überschrieben',
|
||||
@@ -207,22 +260,30 @@ export const de = {
|
||||
|
||||
/** Russian dictionary, checked against the English keys. */
|
||||
export const ru = {
|
||||
title: 'Веб-поиск',
|
||||
description: 'Настройте поисковую систему DeepSeek.',
|
||||
useSearchApi: 'Используйте новый веб-поиск API',
|
||||
useSearchApiHint: 'По умолчанию отключено: поиск осуществляется по сообщениям DeepSeek. Информацию о новых функциях и ценах веб-поиска API см. ',
|
||||
title: 'Веб-поиск и загрузка',
|
||||
description: 'Выберите поставщиков для веб-поиска и загрузки веб-страниц.',
|
||||
searchSection: 'Поставщик веб-поиска',
|
||||
fetchSection: 'Поставщик загрузки веб-страниц',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (прежняя версия)',
|
||||
legacyKeyHint: 'Запросы модели аккаунта используют активный аккаунт; остальные — этот ключ API ({ref}), по умолчанию общий с настройками моделей.',
|
||||
maxUses: 'Максимум поисков на запрос',
|
||||
maxUsesHint: 'Максимум серверных поисков на запрос Messages: положительное целое, по умолчанию 5.',
|
||||
invalidMaxUses: 'Введите положительное целое или оставьте пустым для значения по умолчанию.',
|
||||
providerLocal: 'Прямая загрузка',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (не загружен)',
|
||||
providerAuto: 'Автоматически',
|
||||
providersFailed: 'Не удалось загрузить список поставщиков, поэтому это меню может быть неполным.',
|
||||
retry: 'Повторить',
|
||||
pluginProviderHint: 'Настройте этого поставщика в настройках его собственного плагина.',
|
||||
credential: 'Ключ API',
|
||||
baseUrl: 'Базовый URL',
|
||||
baseUrlHint: 'Включите префикс API. Оставьте пустым, чтобы использовать официальный адрес. Поставщик добавляет {path}.',
|
||||
apiKeyUnsetApi: 'Ключ не настроен.',
|
||||
searchGuide: 'Руководство по поиску и получению',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'Ключ API',
|
||||
apiKeyHint: 'Хранится вне файла настроек. Оставьте пустым, чтобы сохранить текущий ключ.',
|
||||
apiKeySet: 'Ключ настроен.',
|
||||
apiKeyUnset: 'Ключ не настроен; только разговоры, использующие модель учетной записи DeepSeek, могут выполнять поиск через конечную точку по умолчанию.',
|
||||
baseUrl: 'Базовый URL-адрес сообщений API',
|
||||
baseUrlHint: 'Включите префикс API, например https://api.deepseek.com/anthropic/v1.. Поставщик добавляет /messages. Оставьте пустым, чтобы восстановить значение по умолчанию.',
|
||||
searchApiBaseUrl: 'Найдите/получите базовый URL-адрес API',
|
||||
searchApiBaseUrlHint: 'Включите префикс API, например https://api.deepseek.com/v1/tools.. Поставщик добавляет /search или /fetch. Оставьте пустым, чтобы восстановить значение по умолчанию.',
|
||||
maxUses: 'Максимальное количество запросов на запрос',
|
||||
maxUsesHint: 'Сколько поисков вспомогательная модель может выполнить в рамках одного запроса сообщений. Применяется только к сообщениям API.',
|
||||
count: 'Результаты по поиску',
|
||||
countHint: 'Результаты, возвращаемые по запросу: целое число от 1 до 50, по умолчанию 10. Это контролирует количество результатов, а не поисковые вызовы. web_search хранит не больше источников, чем его собственный лимит результатов.',
|
||||
overridden: 'Переопределено',
|
||||
@@ -238,22 +299,30 @@ export const ru = {
|
||||
|
||||
/** Portuguese dictionary, checked against the English keys. */
|
||||
export const pt = {
|
||||
title: 'Pesquisa na web',
|
||||
description: 'Configure o provedor de pesquisa DeepSeek.',
|
||||
useSearchApi: 'Use a nova Pesquisa na Web API',
|
||||
useSearchApiHint: 'Desativado por padrão: a pesquisa é executada em DeepSeek Mensagens. Para conhecer os recursos e preços da nova Pesquisa na Web API, consulte o ',
|
||||
title: 'Pesquisa e obtenção na web',
|
||||
description: 'Escolha os provedores usados pela pesquisa na web e pela obtenção na web.',
|
||||
searchSection: 'Provedor de pesquisa na web',
|
||||
fetchSection: 'Provedor de obtenção na web',
|
||||
providerDeepSeek: 'DeepSeek Search API',
|
||||
providerLegacy: 'DeepSeek (legado)',
|
||||
legacyKeyHint: 'Pedidos do modelo de conta usam a conta conectada; os demais usam esta chave API ({ref}), compartilhada com Modelos por padrão.',
|
||||
maxUses: 'Máximo de pesquisas por pedido',
|
||||
maxUsesHint: 'Máximo de pesquisas no servidor por pedido Messages: inteiro positivo, padrão 5.',
|
||||
invalidMaxUses: 'Insira um inteiro positivo ou deixe vazio para usar o padrão.',
|
||||
providerLocal: 'Obtenção direta',
|
||||
providerDeepSeekFetch: 'DeepSeek Fetch API',
|
||||
providerMissing: '{id} (não carregado)',
|
||||
providerAuto: 'Automático',
|
||||
providersFailed: 'Não foi possível carregar a lista de provedores, por isso este menu pode estar incompleto.',
|
||||
retry: 'Tentar novamente',
|
||||
pluginProviderHint: 'Configure este provedor nas configurações do próprio plugin.',
|
||||
credential: 'Chave de API',
|
||||
baseUrl: 'URL base',
|
||||
baseUrlHint: 'Inclua o prefixo da API. Deixe em branco para usar o endereço oficial. O provedor anexa {path}.',
|
||||
apiKeyUnsetApi: 'Nenhuma chave está configurada.',
|
||||
searchGuide: 'Guia de pesquisa e busca',
|
||||
searchGuideUrl: 'https://api-docs.deepseek.com/guides/search',
|
||||
apiKey: 'API chave',
|
||||
apiKeyHint: 'Armazenado fora do arquivo de configurações. Deixe em branco para manter a chave atual.',
|
||||
apiKeySet: 'Uma chave está configurada.',
|
||||
apiKeyUnset: 'Nenhuma chave está configurada; somente conversas que usam um modelo de conta DeepSeek podem pesquisar, por meio do endpoint padrão.',
|
||||
baseUrl: 'URL base de mensagens API',
|
||||
baseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/anthropic/v1. O provedor anexa /messages. Deixe em branco para restaurar o padrão.',
|
||||
searchApiBaseUrl: 'Pesquisar/buscar API URL base',
|
||||
searchApiBaseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/v1/tools. O provedor anexa /search ou /fetch. Deixe em branco para restaurar o padrão.',
|
||||
maxUses: 'Máximo de pesquisas por solicitação',
|
||||
maxUsesHint: 'Quantas pesquisas o modelo auxiliar pode executar em uma solicitação de Mensagens. Aplica-se somente às Mensagens API.',
|
||||
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.',
|
||||
overridden: 'Substituído',
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
/**
|
||||
* The web-search page's staged form over three settings namespaces: `web`,
|
||||
* whose provider selection the Search API switch writes, `web-search-deepseek`
|
||||
* for Messages search, and `web-deepseek-api` for the Search and Fetch APIs.
|
||||
* The web search and fetch page's staged form over three settings namespaces:
|
||||
* `web`, whose `searchProvider` and `fetchProvider` the two provider menus
|
||||
* write, `web-deepseek-api` for the DeepSeek Search and Fetch APIs, and
|
||||
* `web-search-deepseek` for legacy Messages search.
|
||||
*
|
||||
* The switch selects `deepseek-search-api` and `deepseek-fetch-api` together, or
|
||||
* Messages search with local HTTP fetch. A selection equal to the deployment's
|
||||
* composed value clears the override instead of storing a copy of it.
|
||||
* A selection equal to the deployment's composed value clears the override
|
||||
* instead of storing a copy of it, and while the deployment composes no
|
||||
* provider for a capability its menu also offers automatic selection, which
|
||||
* clears the override. The menus offer the providers the Host reports through
|
||||
* `web/listProviders`, the official ones first in a fixed order and the rest
|
||||
* in registration order, plus a selected id the Host no longer registers. A
|
||||
* failed read is reported on the page with a retry.
|
||||
*
|
||||
* The key is the one control that does not live in a section: its literal
|
||||
* never rides a response, so the page learns only whether one is configured
|
||||
* and writes it through the credentials domain, addressed by the reference the
|
||||
* selected API's section names. It is still staged with the rest of the form,
|
||||
* so one save covers everything the page shows.
|
||||
* 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 legacy key's literal never rides a response, so the page learns only
|
||||
* whether one is configured and writes it through the credentials domain,
|
||||
* addressed by the reference `web-search-deepseek.apiKeyEnv` names. A key
|
||||
* draft belongs to the reference its control addressed when it was typed: it
|
||||
* is dropped when that reference changes or the page stops showing the
|
||||
* control, and a save writes it under that reference. The key is still staged
|
||||
* with the rest of the form, so one save covers everything the page shows.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the ctx.remote merge into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { WebProviderInfo, WebProviderList as ProviderList } from '@deepseek-ai/dsh-web/types'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import {
|
||||
SettingsFormModel, settingsNumberField, settingsTextField,
|
||||
type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope, type SettingsFormScopeSnapshot,
|
||||
type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
@@ -33,17 +45,37 @@ export const WEB_API_NS = 'web-deepseek-api'
|
||||
/** Namespace of the web service's provider selection. */
|
||||
export const WEB_NS = 'web'
|
||||
|
||||
/** Provider ids the switch writes into the web service's selection. */
|
||||
const PROVIDERS = {
|
||||
searchProvider: { api: 'deepseek-search-api', messages: 'deepseek-official' },
|
||||
fetchProvider: { api: 'deepseek-fetch-api', messages: 'http' },
|
||||
/** Provider ids of the official providers whose settings this page edits. */
|
||||
export const PROVIDER_IDS = {
|
||||
apiSearch: 'deepseek-search-api',
|
||||
legacySearch: 'deepseek-official',
|
||||
localFetch: 'http',
|
||||
apiFetch: 'deepseek-fetch-api',
|
||||
} as const
|
||||
|
||||
/** Credential reference the providers resolve when a section names none. */
|
||||
/**
|
||||
* The official providers each menu lists first, in this order, while the Host
|
||||
* registers them; the Host's own list stays in registration order.
|
||||
*/
|
||||
const MENU_ORDER = {
|
||||
search: [PROVIDER_IDS.apiSearch, PROVIDER_IDS.legacySearch],
|
||||
fetch: [PROVIDER_IDS.localFetch, PROVIDER_IDS.apiFetch],
|
||||
} as const satisfies Record<keyof ProviderList, readonly string[]>
|
||||
|
||||
/** Credential reference legacy Messages search resolves when its section names none. */
|
||||
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
|
||||
|
||||
/** Form field the credential control stages under. */
|
||||
const API_KEY_FIELD = 'apiKey'
|
||||
const API_KEY_FIELD = 'legacyApiKey'
|
||||
|
||||
/** Settings-section fields the face edits as text. */
|
||||
export type WebSectionField = 'count' | 'messagesBaseURL' | 'maxUses'
|
||||
|
||||
/** The legacy key control. */
|
||||
export type WebApiKeyField = 'legacyApiKey'
|
||||
|
||||
/** Controls the face edits as text: the key controls and the settings-section fields. */
|
||||
export type WebSearchCardField = WebApiKeyField | WebSectionField
|
||||
|
||||
/** The web service's provider selection. */
|
||||
export interface WebProviderSelection {
|
||||
@@ -54,7 +86,7 @@ export interface WebProviderSelection {
|
||||
}
|
||||
|
||||
/** The Messages search fields this page edits. */
|
||||
export interface WebSearchSettings {
|
||||
export interface WebLegacySettings {
|
||||
/** Credential reference naming the environment key. */
|
||||
apiKeyEnv?: string
|
||||
/** Messages endpoint; blank inherits the provider default. */
|
||||
@@ -65,15 +97,102 @@ export interface WebSearchSettings {
|
||||
|
||||
/** The Search and Fetch API fields this page edits. */
|
||||
export interface WebApiSettings {
|
||||
/** Credential reference naming the environment key. */
|
||||
apiKeyEnv?: string
|
||||
/** Search and Fetch API base with its path prefix; blank inherits the provider default. */
|
||||
baseURL?: string
|
||||
/** Search API results per search. */
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** What the credentials domain last reported, and for which reference. */
|
||||
/** One entry of a provider menu. */
|
||||
export interface WebProviderOption {
|
||||
/** Provider id, written into the selection. */
|
||||
id: string
|
||||
/** Title the provider registered with, if any. */
|
||||
title?: string
|
||||
/** Description the provider registered with, if any; the page shows it under the menu while the provider is selected. */
|
||||
description?: WebProviderInfo['description']
|
||||
/** True for a selected id the Host does not currently register. */
|
||||
missing: boolean
|
||||
}
|
||||
|
||||
/** What the Search section shows for its selected provider. */
|
||||
export type WebSearchKind = 'deepseek' | 'legacy' | 'plugin' | 'none'
|
||||
/** What the Fetch section shows for its selected provider. */
|
||||
export type WebFetchKind = 'local' | 'deepseek' | 'plugin' | 'none'
|
||||
|
||||
/** The legacy key control. */
|
||||
export interface WebApiKeyState extends SettingsFieldState {
|
||||
/** Whether the Host reports a credential configured for the referenced key. */
|
||||
configured: boolean
|
||||
/** Whether the credentials domain accepts a write for it; false disables the control. */
|
||||
writable: boolean
|
||||
/** The credential reference the control writes: `web-search-deepseek.apiKeyEnv`, or its default. */
|
||||
ref: string
|
||||
}
|
||||
|
||||
/** One provider section: its menu and the controls its selection shows. */
|
||||
export interface WebProviderSectionState<K extends string> {
|
||||
/** Id of the selected provider, or the empty string when the selection names none. */
|
||||
selected: string
|
||||
/** Menu entries: the registered providers, plus a selected id that is not registered. */
|
||||
options: readonly WebProviderOption[]
|
||||
/** Which provider-specific controls the section shows. */
|
||||
kind: K
|
||||
/** Whether the menu offers automatic selection, because the deployment composes no provider for this capability. */
|
||||
auto: boolean
|
||||
}
|
||||
|
||||
/** What the web search and fetch page renders. */
|
||||
export interface WebSearchCardState extends SettingsFormShell {
|
||||
/** Invalid edits or failed saves that changing providers must keep visible. */
|
||||
selectionBlocked: boolean
|
||||
/**
|
||||
* Whether the latest provider list read failed. The menus keep the last list
|
||||
* read, or before any list arrives only the selected ids, so they may be
|
||||
* incomplete.
|
||||
*/
|
||||
providersFailed: boolean
|
||||
/** The Search section. */
|
||||
search: WebProviderSectionState<WebSearchKind>
|
||||
/** The Fetch section. */
|
||||
fetch: WebProviderSectionState<WebFetchKind>
|
||||
/** Search API results per search, shown in the Search section while it selects the DeepSeek Search API. */
|
||||
count: SettingsFieldState
|
||||
/** Legacy Messages endpoint. */
|
||||
messagesBaseURL: SettingsFieldState
|
||||
/** Legacy server-side search limit. */
|
||||
maxUses: SettingsFieldState
|
||||
/** Legacy key control, present only while the legacy provider is selected. */
|
||||
legacyKey: WebApiKeyState | undefined
|
||||
}
|
||||
|
||||
/** The registration-side face the page's slot entry injects. */
|
||||
export interface WebSearchCardFace extends Pick<SettingsFormActions, 'save' | 'discard'> {
|
||||
hooks: {
|
||||
/** Page snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
/** Stage draft text for one control. */
|
||||
edit: (field: WebSearchCardField, text: string) => void
|
||||
/** Stage a clear for one section field. */
|
||||
resetField: (field: WebSectionField) => void
|
||||
/** Stage one provider selection; the empty id stages automatic selection. */
|
||||
selectProvider: (field: keyof WebProviderSelection, id: string) => 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
|
||||
}
|
||||
|
||||
/** Settings scopes for the namespaces the page edits. */
|
||||
export interface WebSearchCardScopes {
|
||||
/** The web service's provider selection. */
|
||||
web: SettingsFormScope<WebProviderSelection>
|
||||
/** Search and Fetch API settings. */
|
||||
api: SettingsFormScope<WebApiSettings>
|
||||
/** Legacy Messages search settings. */
|
||||
legacy: SettingsFormScope<WebLegacySettings>
|
||||
}
|
||||
|
||||
/** What the credentials domain last reported for the legacy key control. */
|
||||
interface CredentialState {
|
||||
/** Reference this answer describes; a stale response for another one is dropped. */
|
||||
ref: string
|
||||
@@ -83,77 +202,47 @@ interface CredentialState {
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** What the web-search page renders. */
|
||||
export interface WebSearchCardState extends SettingsFormShell {
|
||||
/** Whether the Host serves both the selection and the Search / Fetch API section. */
|
||||
apiSwitchAvailable: boolean
|
||||
/** Invalid edits or failed saves that switching APIs must keep visible. */
|
||||
apiSwitchBlocked: boolean
|
||||
/** Staged switch position: true selects the Search and Fetch APIs. */
|
||||
useSearchApi: boolean
|
||||
/** Endpoint of the selected API. */
|
||||
baseURL: SettingsFieldState
|
||||
/** Maximum Messages server-tool uses. */
|
||||
maxUses: SettingsFieldState
|
||||
/** Search API results per search. */
|
||||
count: SettingsFieldState
|
||||
/** The staged credential, which starts blank on every load. */
|
||||
apiKey: SettingsFieldState
|
||||
/** Whether the Host reports a credential configured for the referenced key. */
|
||||
apiKeyConfigured: boolean
|
||||
/** Whether the credentials domain accepts a write for it; false disables the control. */
|
||||
apiKeyWritable: boolean
|
||||
}
|
||||
/** What `credentials/describe` answers, by reference. */
|
||||
type CredentialViews = Readonly<Record<string, { configured: boolean; writable: boolean } | undefined>>
|
||||
|
||||
/** The registration-side face the web-search page's slot entry injects. */
|
||||
export interface WebSearchCardFace extends SettingsFormActions {
|
||||
hooks: {
|
||||
/** Page snapshot bound by the renderer as useWebSearchCard. */
|
||||
webSearchCard: SnapshotStore<WebSearchCardState>
|
||||
}
|
||||
/** Stage the API switch: true selects the Search and Fetch APIs, false Messages search with local fetch. */
|
||||
setSearchApi: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
/** Settings scopes for the namespaces the page edits. */
|
||||
export interface WebSearchCardScopes {
|
||||
/** The web service's provider selection. */
|
||||
web: SettingsFormScope<WebProviderSelection>
|
||||
/** Messages search settings. */
|
||||
messages: SettingsFormScope<WebSearchSettings>
|
||||
/** Search and Fetch API settings. */
|
||||
api: SettingsFormScope<WebApiSettings>
|
||||
}
|
||||
|
||||
/** Bridges the three settings scopes and the credentials domain onto the page. */
|
||||
/** Bridges the settings scopes, the provider lists, and the credentials domain onto the page. */
|
||||
export class WebSearchCardController {
|
||||
private readonly selection: SettingsFormModel<WebProviderSelection>
|
||||
private readonly messages: SettingsFormModel<WebSearchSettings>
|
||||
private readonly api: SettingsFormModel<WebApiSettings>
|
||||
private readonly legacy: SettingsFormModel<WebLegacySettings>
|
||||
private readonly store: SnapshotStore<WebSearchCardState>
|
||||
private readonly unsubscribe: (() => void)[]
|
||||
private credential: CredentialState = { ref: '', configured: false, writable: true }
|
||||
private credential: CredentialState
|
||||
private credentialReadQueued = false
|
||||
private credentialReadSequence = 0
|
||||
/** Reference the key draft was typed for; read only while that draft is non-empty. */
|
||||
private draftRef: string
|
||||
/** The draft reference the running or last selection save writes under. */
|
||||
private savingRef: string
|
||||
private droppingDraft = false
|
||||
private providers: ProviderList | undefined
|
||||
private providersFailed = false
|
||||
private providerReadSequence = 0
|
||||
private selectionServed = false
|
||||
private disposed = false
|
||||
|
||||
/**
|
||||
* @param scopes - the bound settings scopes for the page's namespaces.
|
||||
* @param ctx - the page plugin's context, whose `remote.credentials` namespace
|
||||
* answers for the credential the selected section references.
|
||||
* answers for the credential the legacy key control references, and whose
|
||||
* `remote.web` namespace lists the registered providers.
|
||||
*/
|
||||
constructor(
|
||||
private readonly scopes: WebSearchCardScopes,
|
||||
private readonly ctx: ClientContext,
|
||||
) {
|
||||
this.credential = { ref: this.ref(), configured: false, writable: true }
|
||||
this.draftRef = this.ref()
|
||||
this.savingRef = this.draftRef
|
||||
this.selection = new SettingsFormModel(scopes.web, [
|
||||
settingsTextField('searchProvider'), settingsTextField('fetchProvider'),
|
||||
], [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }])
|
||||
this.messages = new SettingsFormModel(scopes.messages, [
|
||||
settingsTextField('baseURL'), settingsNumberField('maxUses'),
|
||||
])
|
||||
this.api = new SettingsFormModel(scopes.api, [
|
||||
settingsTextField('baseURL'),
|
||||
{
|
||||
...settingsNumberField('count'),
|
||||
parse: (text) => {
|
||||
@@ -163,37 +252,138 @@ export class WebSearchCardController {
|
||||
},
|
||||
},
|
||||
])
|
||||
this.legacy = new SettingsFormModel(scopes.legacy, [
|
||||
settingsTextField('baseURL'),
|
||||
{ ...settingsNumberField('maxUses'), parse: (text) => {
|
||||
if (text.trim() === '') return { kind: 'clear' }
|
||||
const value = Number(text)
|
||||
return Number.isInteger(value) && value >= 1 ? { kind: 'set', value } : undefined
|
||||
} },
|
||||
])
|
||||
this.selectionServed = this.selection.shell().available
|
||||
this.store = createSnapshotStore(this.projection())
|
||||
// A staged switch can change which section names the key, without any
|
||||
// scope publishing; a scope change can rename the reference itself.
|
||||
const publish = () => {
|
||||
const ref = this.ref()
|
||||
if (ref !== this.credential.ref) {
|
||||
this.resetCredentialReference(ref)
|
||||
this.queueCredentialRead()
|
||||
}
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
this.unsubscribe = [
|
||||
...[this.selection, this.messages, this.api].map(form => form.bind(() => form.shell()).subscribe(publish)),
|
||||
...[scopes.web, scopes.messages, scopes.api].map(scope => scope.subscribe(() => { this.queueCredentialRead() })),
|
||||
...[this.selection, this.api, this.legacy].map(form => form.bind(() => form.shell()).subscribe(() => { this.publish() })),
|
||||
...[scopes.web, scopes.api, scopes.legacy].map(scope => scope.subscribe(() => { this.queueCredentialRead() })),
|
||||
scopes.web.subscribe(() => {
|
||||
// The provider list comes from the web service; a Host that starts
|
||||
// serving it registers providers this page has not read.
|
||||
const served = this.selection.shell().available
|
||||
if (served && !this.selectionServed) this.refreshProviders()
|
||||
this.selectionServed = served
|
||||
}),
|
||||
]
|
||||
void this.readCredential()
|
||||
this.refreshProviders()
|
||||
}
|
||||
|
||||
/** Whether the Host serves both sections the switch needs. */
|
||||
private switchAvailable(): boolean {
|
||||
return this.selection.shell().available && this.api.shell().available
|
||||
/**
|
||||
* Republish after a form or scope change. A staged selection can hide the
|
||||
* key control without any scope publishing; a scope change can rename the
|
||||
* reference itself.
|
||||
*/
|
||||
private publish(): void {
|
||||
// Dropping a draft stages an edit, which republishes; publish once after it.
|
||||
if (this.droppingDraft) return
|
||||
this.dropStaleKeyDraft()
|
||||
const ref = this.ref()
|
||||
if (ref !== this.credential.ref) {
|
||||
this.resetCredentialReference(ref)
|
||||
this.queueCredentialRead()
|
||||
}
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
/** The staged switch position, read from the staged search selection. */
|
||||
private searchApi(): boolean {
|
||||
return this.switchAvailable() && this.selection.field('searchProvider').text === PROVIDERS.searchProvider.api
|
||||
/** The provider id a selection field stages. */
|
||||
private selected(field: keyof WebProviderSelection): string {
|
||||
return this.selection.field(field).text
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu entries for one capability: the registered official providers in
|
||||
* {@link MENU_ORDER}, then every other registered provider in Host order,
|
||||
* and the selected id when the Host does not register it. Before the list
|
||||
* arrives, the menu holds only the selected id.
|
||||
*/
|
||||
private options(capability: keyof ProviderList, selected: string): WebProviderOption[] {
|
||||
const registered = this.providers?.[capability]
|
||||
if (registered === undefined) return selected === '' ? [] : [{ id: selected, missing: false }]
|
||||
const first: readonly string[] = MENU_ORDER[capability]
|
||||
const ordered = [
|
||||
...first.flatMap(id => registered.filter(info => info.id === id)),
|
||||
...registered.filter(info => !first.includes(info.id)),
|
||||
]
|
||||
const options = ordered.map(info => ({
|
||||
id: info.id,
|
||||
...info.title === undefined ? {} : { title: info.title },
|
||||
...info.description === undefined ? {} : { description: info.description },
|
||||
missing: false,
|
||||
}))
|
||||
if (selected !== '' && !registered.some(info => info.id === selected)) options.push({ id: selected, missing: true })
|
||||
return options
|
||||
}
|
||||
|
||||
/** Whether the Host reports `id` as registered, or has not answered yet. */
|
||||
private registered(capability: keyof ProviderList, id: string): boolean {
|
||||
return this.providers === undefined || this.providers[capability].some(info => info.id === id)
|
||||
}
|
||||
|
||||
private searchKind(): WebSearchKind {
|
||||
const id = this.selected('searchProvider')
|
||||
if (id === '' || !this.registered('search', id)) return 'none'
|
||||
if (id === PROVIDER_IDS.apiSearch) return this.api.shell().available ? 'deepseek' : 'none'
|
||||
if (id === PROVIDER_IDS.legacySearch) return this.legacy.shell().available ? 'legacy' : 'none'
|
||||
return this.providers === undefined ? 'none' : 'plugin'
|
||||
}
|
||||
|
||||
private fetchKind(): WebFetchKind {
|
||||
const id = this.selected('fetchProvider')
|
||||
if (id === '' || !this.registered('fetch', id)) return 'none'
|
||||
if (id === PROVIDER_IDS.localFetch) return 'local'
|
||||
if (id === PROVIDER_IDS.apiFetch) return this.api.shell().available ? 'deepseek' : 'none'
|
||||
return this.providers === undefined ? 'none' : 'plugin'
|
||||
}
|
||||
|
||||
/** The reference the key control addresses, from the legacy settings or the default. */
|
||||
private ref(): string {
|
||||
return this.scopes.legacy.getSnapshot().value?.apiKeyEnv || DEFAULT_API_KEY_REF
|
||||
}
|
||||
|
||||
/** Whether the page shows the key control. */
|
||||
private keyShown(): boolean {
|
||||
return this.searchKind() === 'legacy'
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the key draft once the page no longer shows the control or the
|
||||
* control addresses another reference, so a draft is never written under
|
||||
* another reference or under one no control shows.
|
||||
*/
|
||||
private dropStaleKeyDraft(): void {
|
||||
if (this.selection.field(API_KEY_FIELD).text === '') return
|
||||
if (this.keyShown() && this.draftRef === this.ref()) return
|
||||
this.droppingDraft = true
|
||||
try {
|
||||
this.selection.actions().edit(API_KEY_FIELD, '')
|
||||
} finally {
|
||||
this.droppingDraft = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a key draft for the reference the control addresses now.
|
||||
* @param text - the draft text.
|
||||
*/
|
||||
private editKey(text: string): void {
|
||||
// The page shows no key control, so a draft would belong to no reference.
|
||||
if (!this.keyShown()) return
|
||||
this.draftRef = this.ref()
|
||||
this.selection.actions().edit(API_KEY_FIELD, text)
|
||||
}
|
||||
|
||||
private projection(): WebSearchCardState {
|
||||
const primary = this.messages.shell()
|
||||
const served = [primary, this.selection.shell(), this.api.shell()].filter(item => item.available)
|
||||
const primary = this.selection.shell()
|
||||
const served = [primary, this.api.shell(), this.legacy.shell()].filter(item => item.available)
|
||||
const shell: SettingsFormShell = {
|
||||
available: primary.available,
|
||||
writable: served.every(item => item.writable),
|
||||
@@ -202,24 +392,50 @@ export class WebSearchCardController {
|
||||
saving: served.some(item => item.saving),
|
||||
failed: served.some(item => item.failed),
|
||||
}
|
||||
const searchApi = this.searchApi()
|
||||
const searchSelected = this.selected('searchProvider')
|
||||
const fetchSelected = this.selected('fetchProvider')
|
||||
const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined
|
||||
return {
|
||||
...shell,
|
||||
apiSwitchAvailable: this.switchAvailable(),
|
||||
apiSwitchBlocked: shell.invalid || shell.failed,
|
||||
useSearchApi: searchApi,
|
||||
baseURL: (searchApi ? this.api : this.messages).field('baseURL'),
|
||||
maxUses: this.messages.field('maxUses'),
|
||||
selectionBlocked: shell.invalid || shell.failed,
|
||||
providersFailed: this.providersFailed,
|
||||
search: {
|
||||
selected: searchSelected,
|
||||
options: this.options('search', searchSelected),
|
||||
kind: this.searchKind(),
|
||||
auto: base?.searchProvider === undefined,
|
||||
},
|
||||
fetch: {
|
||||
selected: fetchSelected,
|
||||
options: this.options('fetch', fetchSelected),
|
||||
kind: this.fetchKind(),
|
||||
auto: base?.fetchProvider === undefined,
|
||||
},
|
||||
count: this.api.field('count'),
|
||||
apiKey: this.selection.field(API_KEY_FIELD),
|
||||
apiKeyConfigured: this.credential.configured,
|
||||
apiKeyWritable: this.credential.writable,
|
||||
messagesBaseURL: this.legacy.field('baseURL'),
|
||||
maxUses: this.legacy.field('maxUses'),
|
||||
legacyKey: this.keyShown() ? { ...this.selection.field(API_KEY_FIELD), ...this.credential } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** The reference the selected API's section names, or the providers' default. */
|
||||
private ref(): string {
|
||||
return refOf(this.searchApi() ? this.scopes.api.getSnapshot() : this.scopes.messages.getSnapshot())
|
||||
/**
|
||||
* 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
|
||||
* until a later read succeeds.
|
||||
*/
|
||||
refreshProviders(): void {
|
||||
void this.readProviders()
|
||||
}
|
||||
|
||||
private async readProviders(): Promise<void> {
|
||||
if (this.disposed) return
|
||||
const sequence = ++this.providerReadSequence
|
||||
const response = await this.ctx.remote.web.listProviders()
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
|
||||
if (this.disposed || sequence !== this.providerReadSequence) return
|
||||
if (response.ok) this.providers = response.value
|
||||
this.providersFailed = !response.ok
|
||||
this.publish()
|
||||
}
|
||||
|
||||
/** Clear the old reference's answer and invalidate its pending reads. */
|
||||
@@ -239,13 +455,15 @@ export class WebSearchCardController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the credentials domain about the reference the selected section currently names.
|
||||
* Ask the credentials domain about the reference the key control currently names.
|
||||
*
|
||||
* Only the latest read for the selected reference publishes its answer.
|
||||
* A write's readback retains its own result even if another read starts.
|
||||
* @returns the answer for this read, or undefined after failure, disposal, or a reference change.
|
||||
* Only the latest read publishes its answer, and only while its reference
|
||||
* is unchanged. A write's readback retains its own result even if
|
||||
* another read starts or the reference changes.
|
||||
* @param written - a reference a save just wrote, asked about in the same read.
|
||||
* @returns the answer by reference, or undefined after failure or disposal.
|
||||
*/
|
||||
private async readCredential(): Promise<CredentialState | undefined> {
|
||||
private async readCredential(written?: string): Promise<CredentialViews | undefined> {
|
||||
if (this.disposed) return
|
||||
const ref = this.ref()
|
||||
if (ref !== this.credential.ref) {
|
||||
@@ -255,23 +473,26 @@ export class WebSearchCardController {
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
const sequence = ++this.credentialReadSequence
|
||||
const response = await this.ctx.remote.credentials.describe([ref])
|
||||
const names = [...new Set([ref, ...written === undefined ? [] : [written]])]
|
||||
const response = await this.ctx.remote.credentials.describe(names)
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
|
||||
if (this.disposed || !response.ok || ref !== this.ref()) return
|
||||
const view = response.value[ref]
|
||||
const next: CredentialState = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
// An unknown reference is treated as writable: the control stays usable
|
||||
// and the Host is what refuses, rather than the page guessing a refusal.
|
||||
writable: view?.writable ?? true,
|
||||
if (this.disposed || !response.ok) return
|
||||
const views: CredentialViews = response.value
|
||||
if (sequence !== this.credentialReadSequence) return views
|
||||
if (ref !== this.ref()) {
|
||||
// The reference changed outside a publication: drop this answer and read the new one.
|
||||
this.publish()
|
||||
return views
|
||||
}
|
||||
if (sequence === this.credentialReadSequence
|
||||
&& (next.configured !== this.credential.configured || next.writable !== this.credential.writable)) {
|
||||
const view = views[ref]
|
||||
// An unknown reference is treated as writable: the control stays usable
|
||||
// and the Host is what refuses, rather than the page guessing a refusal.
|
||||
const next: CredentialState = { ref, configured: view?.configured ?? false, writable: view?.writable ?? true }
|
||||
if (next.configured !== this.credential.configured || next.writable !== this.credential.writable) {
|
||||
this.credential = next
|
||||
this.store.set(this.projection())
|
||||
this.publish()
|
||||
}
|
||||
return next
|
||||
return views
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,88 +509,87 @@ export class WebSearchCardController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the face the page's slot registration injects. `baseURL` edits the
|
||||
* selected API's endpoint; `maxUses` and `count` edit their own sections.
|
||||
* Build the face the page's slot registration injects.
|
||||
* @returns the page's snapshot and its form actions.
|
||||
*/
|
||||
inject(): WebSearchCardFace {
|
||||
const formOf = (field: string): { actions(): SettingsFormActions } => {
|
||||
if (field === API_KEY_FIELD) return this.selection
|
||||
if (field === 'maxUses') return this.messages
|
||||
if (field === 'count') return this.api
|
||||
return this.searchApi() ? this.api : this.messages
|
||||
}
|
||||
return {
|
||||
hooks: { webSearchCard: this.store },
|
||||
edit: (field, text) => { formOf(field).actions().edit(field, text) },
|
||||
resetField: (field) => { formOf(field).actions().resetField(field) },
|
||||
setSearchApi: (enabled) => { this.select(enabled) },
|
||||
edit: (field, text) => {
|
||||
if (field === 'legacyApiKey') { this.editKey(text); return }
|
||||
if (field === 'messagesBaseURL' || field === 'maxUses') {
|
||||
this.legacy.actions().edit(field === 'messagesBaseURL' ? 'baseURL' : field, text)
|
||||
return
|
||||
}
|
||||
this.api.actions().edit(field, text)
|
||||
},
|
||||
resetField: (field) => {
|
||||
const form = field === 'messagesBaseURL' || field === 'maxUses' ? this.legacy : this.api
|
||||
form.actions().resetField(field === 'messagesBaseURL' ? 'baseURL' : field)
|
||||
},
|
||||
selectProvider: (field, id) => { this.select(field, id) },
|
||||
retryProviders: () => { this.refreshProviders() },
|
||||
resolveText: text => this.ctx.locale.resolveText(text),
|
||||
save: () => { void this.save() },
|
||||
discard: () => {
|
||||
for (const form of [this.selection, this.messages, this.api]) form.actions().discard()
|
||||
for (const form of [this.selection, this.api, this.legacy]) form.actions().discard()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage both provider selections for one switch position. A target equal to
|
||||
* the composed value clears the override so the deployment default keeps
|
||||
* Stage one provider selection. Automatic selection and a target equal to
|
||||
* the composed value clear the override, so the deployment default keeps
|
||||
* applying.
|
||||
* @param enabled - true for the Search and Fetch APIs.
|
||||
* @param field - the selection field.
|
||||
* @param id - the provider id to select, or the empty string for automatic selection.
|
||||
*/
|
||||
private select(enabled: boolean): void {
|
||||
const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined
|
||||
const actions = this.selection.actions()
|
||||
for (const field of ['searchProvider', 'fetchProvider'] as const) {
|
||||
const target = PROVIDERS[field][enabled ? 'api' : 'messages']
|
||||
if (base?.[field] === target) actions.resetField(field)
|
||||
else actions.edit(field, target)
|
||||
}
|
||||
private select(field: keyof WebProviderSelection, id: string): void {
|
||||
const composed = (this.scopes.web.getSnapshot().base as WebProviderSelection | undefined)?.[field]
|
||||
if (id === '' || composed === id) this.selection.actions().resetField(field)
|
||||
else this.selection.actions().edit(field, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the provider sections before the selection, so a switch takes effect
|
||||
* only after the endpoint it uses has landed. A refused section stops the
|
||||
* selection from saving and keeps every remaining draft for correction.
|
||||
* 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
|
||||
* selection from saving and keeps every remaining draft for correction. The
|
||||
* key draft's reference is fixed when the selection save plans its writes,
|
||||
* so a provider change during the save does not redirect a key the save
|
||||
* already planned.
|
||||
* @returns settlement after every attempted write.
|
||||
*/
|
||||
private async save(): Promise<void> {
|
||||
const state = this.projection()
|
||||
if (!state.available || state.invalid || state.saving) return
|
||||
for (const form of [this.messages, this.api]) {
|
||||
for (const form of [this.api, this.legacy]) {
|
||||
if (!form.shell().available || !form.shell().dirty) continue
|
||||
await form.save()
|
||||
if (form.shell().failed) return
|
||||
}
|
||||
if (this.selection.shell().dirty) await this.selection.save()
|
||||
if (!this.selection.shell().dirty) return
|
||||
this.savingRef = this.draftRef
|
||||
await this.selection.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the staged key, then re-read whether the Host now holds one.
|
||||
* Write the staged key under the reference its draft belonged to when the
|
||||
* save started, then re-read whether the Host now holds it.
|
||||
* @param value - the staged credential literal.
|
||||
* @returns whether the Host reports a configured credential afterwards.
|
||||
*/
|
||||
private async writeKey(value: string): Promise<boolean> {
|
||||
const ref = this.savingRef
|
||||
// Refusals surface through the re-read below: the Host is the only
|
||||
// authority on whether the key now exists.
|
||||
await this.ctx.remote.credentials.set(this.ref(), value)
|
||||
return (await this.readCredential())?.configured ?? false
|
||||
await this.ctx.remote.credentials.set(ref, value)
|
||||
return (await this.readCredential(ref))?.[ref]?.configured ?? false
|
||||
}
|
||||
|
||||
/** Release subscriptions and prevent queued reads or late answers from publishing. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
for (const off of this.unsubscribe) off()
|
||||
for (const form of [this.selection, this.messages, this.api]) form.dispose()
|
||||
for (const form of [this.selection, this.api, this.legacy]) form.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential reference a section names, or the providers' default.
|
||||
* @param snapshot - the section's current scope snapshot.
|
||||
* @returns the reference to address.
|
||||
*/
|
||||
function refOf(snapshot: SettingsFormScopeSnapshot<{ apiKeyEnv?: string }>): string {
|
||||
const declared = snapshot.value?.apiKeyEnv
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
|
||||
}
|
||||
|
||||
@@ -29,12 +29,14 @@ async function bench(served?: string[]) {
|
||||
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
|
||||
? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) }
|
||||
: { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } }))
|
||||
const listProviders = vi.fn(() => Promise.resolve({ ok: true, value: { search: [], fetch: [] } }))
|
||||
const remote = new TestRemote(ctx, {
|
||||
credentials: { describe: describeCredentials, set: vi.fn() },
|
||||
settings: { describe: describeSettings },
|
||||
web: { listProviders },
|
||||
})
|
||||
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, listProviders, remote }
|
||||
}
|
||||
|
||||
/** The Plugins page's item slot, as its owner declares it. */
|
||||
@@ -51,11 +53,11 @@ describe('ui-settings-web-search apply', () => {
|
||||
})
|
||||
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'configForms'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms'])
|
||||
})
|
||||
|
||||
it('registers the page while the Host serves the namespace, titled in the active locale', async () => {
|
||||
const { ctx, slots } = await bench(['web-search-deepseek'])
|
||||
it('registers the page while the Host serves the web namespace, titled in the active locale', async () => {
|
||||
const { ctx, slots } = await bench(['web'])
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -63,14 +65,14 @@ describe('ui-settings-web-search apply', () => {
|
||||
await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) })
|
||||
const entry = slots.entries('plugins.item')[0]!
|
||||
expect(entry.options).toMatchObject({ id: 'web-search', order: 20 })
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索')
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索与获取')
|
||||
expect(entry.locale).toBe(NS)
|
||||
const face = (entry.inject as () => Pick<WebSearchCardFace, 'hooks'>)()
|
||||
expect(Object.keys(face.hooks)).toEqual(['webSearchCard'])
|
||||
})
|
||||
|
||||
it('registers nothing while the namespace is not served', async () => {
|
||||
const { ctx, slots, describeSettings } = await bench(['shell'])
|
||||
it('registers nothing while the web namespace is not served', async () => {
|
||||
const { ctx, slots, describeSettings } = await bench(['shell', 'web-deepseek-api'])
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
|
||||
@@ -78,14 +80,16 @@ describe('ui-settings-web-search apply', () => {
|
||||
expect(slots.entries('plugins.item')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-reads the credential when the Host reports the watched reference changed, and ignores another', async () => {
|
||||
it('re-reads the credentials when the Host reports a watched reference changed, and ignores another', async () => {
|
||||
const { ctx, slots, describeCredentials, remote } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
|
||||
describeCredentials.mockClear()
|
||||
|
||||
remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY'])
|
||||
for (const ref of ['UNWATCHED_KEY', 'DEEPSEEK_SEARCH_API_KEY', 'DEEPSEEK_FETCH_API_KEY']) {
|
||||
remote.emit('credentials/reference-updated', [ref])
|
||||
}
|
||||
await Promise.resolve()
|
||||
expect(describeCredentials).not.toHaveBeenCalled()
|
||||
|
||||
@@ -95,8 +99,27 @@ describe('ui-settings-web-search apply', () => {
|
||||
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('re-reads the providers when the Host reports a registration change or the connection resets', async () => {
|
||||
const { ctx, slots, listProviders, remote } = await bench()
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(1) })
|
||||
|
||||
remote.emit('web/providers-changed', [])
|
||||
await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(2) })
|
||||
ctx.emit('connection/reset')
|
||||
await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(3) })
|
||||
|
||||
await fiber.dispose()
|
||||
remote.emit('web/providers-changed', [])
|
||||
ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
expect(listProviders).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('collapses the page on teardown', async () => {
|
||||
const { ctx, slots } = await bench(['web-search-deepseek'])
|
||||
const { ctx, slots } = await bench(['web'])
|
||||
declareRoot(slots)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The web-search page as the Plugins page renders it: its API switch, key control, two fields, and their resets. */
|
||||
/** The web search and fetch page as the Plugins page renders it: two provider menus and the selected providers' own settings. */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { WebSearchCard, type WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
|
||||
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
|
||||
import type { WebProviderOption, WebSearchCardState } from '../src/client/web-search-card-controller.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = (key: keyof typeof en) => en[key]
|
||||
const t = ((key: keyof typeof en, params?: Record<string, unknown>) =>
|
||||
en[key].replace(/\{(\w+)\}/g, (match, name: string) => params !== undefined && name in params ? String(params[name]) : match)) as WebSearchCardProps['t']
|
||||
|
||||
const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false }
|
||||
|
||||
@@ -20,164 +21,346 @@ function field(text: string, rest: Partial<SettingsFieldState> = {}): SettingsFi
|
||||
return { text, overridden: false, invalid: false, ...rest }
|
||||
}
|
||||
|
||||
const endpoints = {
|
||||
messages: field(''),
|
||||
api: field('https://api.deepseek.com/v1/tools'),
|
||||
/** 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,可能产生计费',
|
||||
},
|
||||
'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,可能产生计费',
|
||||
},
|
||||
}
|
||||
|
||||
function options(...ids: string[]): WebProviderOption[] {
|
||||
return ids.map(id => ({ id, ...DESCRIPTIONS[id] === undefined ? {} : { description: DESCRIPTIONS[id] }, missing: false }))
|
||||
}
|
||||
|
||||
/** Menu entries as the controller orders them. */
|
||||
const SEARCH_OPTIONS = options('deepseek-search-api', 'deepseek-official')
|
||||
const FETCH_OPTIONS = options('http', 'deepseek-fetch-api')
|
||||
|
||||
/** The locale service's resolution, with Chinese active. */
|
||||
const resolveText = vi.fn((text: string | { en: string; [locale: string]: string }) => typeof text === 'string' ? text : text.zh ?? text.en)
|
||||
|
||||
/** The shipped selection: the DeepSeek Search API with local fetch. */
|
||||
function shipped(): WebSearchCardState {
|
||||
return {
|
||||
...settled,
|
||||
selectionBlocked: false,
|
||||
providersFailed: false,
|
||||
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'),
|
||||
messagesBaseURL: field(''),
|
||||
maxUses: field('5'),
|
||||
legacyKey: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Both DeepSeek API providers selected. */
|
||||
function apis(): WebSearchCardState {
|
||||
const base = shipped()
|
||||
return { ...base, fetch: { ...base.fetch, selected: 'deepseek-fetch-api', kind: 'deepseek' } }
|
||||
}
|
||||
|
||||
/** Legacy Messages search selected, with its key control. */
|
||||
function legacy(key: Partial<NonNullable<WebSearchCardState['legacyKey']>> = {}): WebSearchCardState {
|
||||
const base = shipped()
|
||||
return {
|
||||
...base,
|
||||
search: { ...base.search, selected: 'deepseek-official', kind: 'legacy' },
|
||||
legacyKey: { ...field(''), ref: 'DEEPSEEK_API_KEY', configured: false, writable: true, ...key },
|
||||
}
|
||||
}
|
||||
|
||||
/** Search and Fetch providers that take no settings on this page. */
|
||||
function plugins(): WebSearchCardState {
|
||||
const base = shipped()
|
||||
return {
|
||||
...base,
|
||||
search: { selected: 'exa', kind: 'plugin', auto: false, options: [{ id: 'exa', title: 'Exa', missing: false }] },
|
||||
fetch: { selected: '', kind: 'none', auto: false, options: [] },
|
||||
}
|
||||
}
|
||||
|
||||
function cardActions() {
|
||||
return { edit: vi.fn(), resetField: vi.fn(), setSearchApi: vi.fn(), save: vi.fn(), discard: vi.fn() }
|
||||
return {
|
||||
edit: vi.fn(), resetField: vi.fn(), selectProvider: vi.fn(), retryProviders: vi.fn(), save: vi.fn(), discard: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(state: WebSearchCardState = shipped(), view: 'page' | 'summary' = 'page') {
|
||||
const store = createSnapshotStore<WebSearchCardState>(state)
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, view, t, resolveText, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return { actions, store }
|
||||
}
|
||||
|
||||
/** The provider menus, named by their section title followed by the selected provider. */
|
||||
const searchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.searchSection} `) })
|
||||
const fetchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.fetchSection} `) })
|
||||
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)
|
||||
|
||||
describe('WebSearchCard', () => {
|
||||
function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
...settled,
|
||||
apiSwitchAvailable: true,
|
||||
apiSwitchBlocked: state.invalid === true || state.failed === true,
|
||||
baseURL: state.useSearchApi === true ? endpoints.api : endpoints.messages, useSearchApi: false, maxUses: field('5'),
|
||||
count: field('10'),
|
||||
apiKey: field(''),
|
||||
apiKeyConfigured: false,
|
||||
apiKeyWritable: true,
|
||||
...state,
|
||||
it('offers legacy search in both locales and renders its own settings', () => {
|
||||
const { actions } = renderPage(legacy())
|
||||
expect(searchMenu().textContent).toBe('DeepSeek (legacy)')
|
||||
expect(zh.providerLegacy).toBe('DeepSeek(旧版)')
|
||||
expect(screen.queryByLabelText(en.count)).toBeNull()
|
||||
fireEvent.change(screen.getByLabelText(en.credential), { target: { value: 'new-key' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '8' } })
|
||||
expect(actions.edit.mock.calls).toEqual([
|
||||
['legacyApiKey', 'new-key'], ['messagesBaseURL', 'https://messages.test/v1'], ['maxUses', '8'],
|
||||
])
|
||||
})
|
||||
|
||||
it('resets the legacy endpoint and search limit under their own names', () => {
|
||||
const { actions } = renderPage({
|
||||
...legacy(), messagesBaseURL: field('https://messages.test/v1', { overridden: true }), maxUses: field('8', { overridden: true }),
|
||||
})
|
||||
const actions = cardActions()
|
||||
const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
return actions
|
||||
}
|
||||
for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button)
|
||||
expect(actions.resetField.mock.calls).toEqual([['messagesBaseURL'], ['maxUses']])
|
||||
})
|
||||
|
||||
it('renders its one-liner alone in the summary view', () => {
|
||||
const store = createSnapshotStore<WebSearchCardState>({
|
||||
...settled, baseURL: endpoints.messages, apiSwitchAvailable: true, apiSwitchBlocked: false, useSearchApi: false,
|
||||
maxUses: field('5'), count: field('10'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true,
|
||||
})
|
||||
const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps
|
||||
render(<WebSearchCard {...props} />)
|
||||
|
||||
renderPage(shipped(), 'summary')
|
||||
expect(document.body.textContent).toBe(en.description)
|
||||
expect(screen.queryByLabelText(en.apiKey)).toBeNull()
|
||||
expect(screen.queryByLabelText(en.count)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports whether a key is configured without ever showing one', () => {
|
||||
renderWebSearch({ apiKeyConfigured: true })
|
||||
|
||||
expect(screen.getByText(en.apiKeySet)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.apiKey)).toHaveProperty('type', 'password')
|
||||
it('titles the page and its two sections by the provider each one picks', () => {
|
||||
renderPage()
|
||||
expect(en.title).toBe('Web search and fetch')
|
||||
expect(zh.title).toBe('网页搜索与获取')
|
||||
expect([en.searchSection, en.fetchSection]).toEqual(['Web search provider', 'Web fetch provider'])
|
||||
expect([zh.searchSection, zh.fetchSection]).toEqual(['网页搜索提供方', '网页获取提供方'])
|
||||
expect(screen.getByRole('heading', { name: en.searchSection })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.fetchSection })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: en.searchSection })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: en.fetchSection })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the key control usable while the settings document is read-only', () => {
|
||||
const actions = renderWebSearch({ writable: false })
|
||||
|
||||
const key = screen.getByLabelText(en.apiKey)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true)
|
||||
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
|
||||
expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
|
||||
it('names each provider menu by its section title and the selected provider, with no label of its own', () => {
|
||||
renderPage()
|
||||
expect(searchMenu().getAttribute('aria-labelledby')).toBe('plugin-config-web-search-section plugin-config-web-search-provider-value')
|
||||
expect(screen.getByRole('button', { name: `${en.searchSection} ${en.providerDeepSeek}` })).toBe(searchMenu())
|
||||
expect(screen.getByRole('button', { name: `${en.fetchSection} ${en.providerLocal}` })).toBe(fetchMenu())
|
||||
for (const title of [en.searchSection, en.fetchSection]) expect(screen.getAllByText(title)).toHaveLength(1)
|
||||
expect(document.querySelectorAll('label[for="plugin-config-web-search-provider"], label[for="plugin-config-web-fetch-provider"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disables the key control when the reference itself is not writable', () => {
|
||||
// A key coming from the process environment: the settings document is
|
||||
// writable, the credential is not.
|
||||
renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
|
||||
|
||||
expect(screen.getByLabelText(en.apiKey)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', false)
|
||||
it('shows the Search API result count in a panel, and gives local fetch no panel', () => {
|
||||
renderPage()
|
||||
expect(searchMenu().textContent).toBe(en.providerDeepSeek)
|
||||
expect(fetchMenu().textContent).toBe(en.providerLocal)
|
||||
expect(region(en.searchSection).getByText(DESCRIPTIONS['deepseek-search-api']!.zh)).toBeTruthy()
|
||||
expect(region(en.fetchSection).getByText('本机获取说明')).toBeTruthy()
|
||||
expect(region(en.searchSection).getByLabelText(en.count)).toHaveProperty('value', '10')
|
||||
for (const label of [en.credential, en.baseUrl]) expect(screen.queryByLabelText(label)).toBeNull()
|
||||
expect(region(en.fetchSection).queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('stages the endpoint, the search budget, and their resets', () => {
|
||||
const actions = renderWebSearch({
|
||||
baseURL: { ...endpoints.messages, ...field('https://search.test/v1', { overridden: true }) },
|
||||
maxUses: field('3', { overridden: true }),
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://other.test' } })
|
||||
fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '4' } })
|
||||
const resets = screen.getAllByRole('button', { name: en.reset })
|
||||
expect(resets).toHaveLength(2)
|
||||
for (const reset of resets) fireEvent.click(reset)
|
||||
|
||||
expect(actions.edit.mock.calls).toEqual([
|
||||
['baseURL', 'https://other.test'],
|
||||
['maxUses', '4'],
|
||||
])
|
||||
expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
|
||||
})
|
||||
it.each([
|
||||
[false, en.baseUrl, ''],
|
||||
[true, en.searchApiBaseUrl, 'https://api.deepseek.com/v1/tools'],
|
||||
])('shows the selected API endpoint with the Search API %s and offers no reset before an override', (searchApi, label, value) => {
|
||||
const actions = renderWebSearch({ useSearchApi: searchApi })
|
||||
const input = screen.getByLabelText(label)
|
||||
expect(input).toHaveProperty('value', value)
|
||||
expect(screen.queryByText(en.overridden)).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: en.reset })).toBeNull()
|
||||
fireEvent.change(input, { target: { value: 'https://custom.test' } })
|
||||
expect(actions.edit).toHaveBeenCalledWith('baseURL', 'https://custom.test')
|
||||
it('shows the DeepSeek Fetch API description under its provider menu', () => {
|
||||
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(region(en.fetchSection).queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults the feature off and stages enabling it', () => {
|
||||
const actions = renderWebSearch()
|
||||
const toggle = screen.getByRole('switch', { name: en.useSearchApi })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(screen.getByLabelText(en.maxUses)).toBeTruthy()
|
||||
expect(screen.getByText(en.baseUrlHint)).toBeTruthy()
|
||||
expect(screen.queryByText(en.searchApiBaseUrlHint)).toBeNull()
|
||||
expect(zh.useSearchApi).toBe('启用新版 Web 搜索 API')
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setSearchApi).toHaveBeenCalledWith(true)
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
it('edits and resets the Search API result count', () => {
|
||||
const { actions } = renderPage({ ...apis(), count: field('20', { overridden: true }) })
|
||||
const search = region(en.searchSection)
|
||||
expect(search.getByLabelText(en.count)).toHaveProperty('value', '20')
|
||||
fireEvent.change(search.getByLabelText(en.count), { target: { value: '30' } })
|
||||
fireEvent.click(search.getByRole('button', { name: en.reset }))
|
||||
expect(actions.edit.mock.calls).toEqual([['count', '30']])
|
||||
expect(actions.resetField.mock.calls).toEqual([['count']])
|
||||
})
|
||||
|
||||
it('links the switch hint to the localized search and fetch guide', () => {
|
||||
renderWebSearch()
|
||||
it('shows the DeepSeek search guide after the Search API description, and the Fetch API description', () => {
|
||||
renderPage(apis())
|
||||
expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch)
|
||||
const guide = screen.getByRole('link', { name: en.searchGuide })
|
||||
expect(guide).toHaveProperty('href', en.searchGuideUrl)
|
||||
expect(guide.getAttribute('target')).toBe('_blank')
|
||||
expect(guide.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(guide.parentElement?.textContent).toBe(`${en.useSearchApiHint}${en.searchGuide}`)
|
||||
expect(`${zh.useSearchApiHint}${zh.searchGuide}`).toBe('默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见网页搜索与抓取指南')
|
||||
expect(zh.searchGuideUrl).toBe('https://api-docs.deepseek.com/zh-cn/guides/search')
|
||||
// The description comes first; the guide link is the page's own line after it.
|
||||
expect(paragraphs(en.searchSection).slice(0, 2)).toEqual([DESCRIPTIONS['deepseek-search-api']!.zh, en.searchGuide])
|
||||
})
|
||||
|
||||
it('omits the switch while the Host serves no Search and Fetch API section', () => {
|
||||
renderWebSearch({ apiSwitchAvailable: false })
|
||||
expect(screen.queryByRole('switch', { name: en.useSearchApi })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: en.searchGuide })).toBeNull()
|
||||
expect(screen.getByLabelText(en.maxUses)).toBeTruthy()
|
||||
it('shows no settings while neither provider takes them', () => {
|
||||
renderPage(plugins())
|
||||
expect(screen.queryByText(en.credential)).toBeNull()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([{ invalid: true }, { failed: true }, { saving: true }])('keeps invalid or unsaved failures visible while the switch is locked: %j', (state) => {
|
||||
renderWebSearch(state)
|
||||
expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true)
|
||||
expect(screen.getByLabelText(en.maxUses)).toBeTruthy()
|
||||
it('selects a provider from the menu and leaves the current one alone', () => {
|
||||
const { actions } = renderPage()
|
||||
fireEvent.click(searchMenu())
|
||||
expect(searchMenu().getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerDeepSeek, en.providerLegacy])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeek }))
|
||||
expect(screen.queryByRole('menuitem')).toBeNull()
|
||||
fireEvent.click(fetchMenu())
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerLocal, en.providerDeepSeekFetch])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en.providerLocal }))
|
||||
expect(actions.selectProvider).not.toHaveBeenCalled()
|
||||
fireEvent.click(fetchMenu())
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeekFetch }))
|
||||
expect(actions.selectProvider).toHaveBeenLastCalledWith('fetchProvider', 'deepseek-fetch-api')
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows each selected provider’s registered description as plain text, with no action of its own', () => {
|
||||
renderPage()
|
||||
expect(region(en.fetchSection).getByText('本机获取说明').tagName).toBe('P')
|
||||
expect(region(en.fetchSection).queryAllByRole('button').map(button => button.id)).toEqual(['plugin-config-web-fetch-provider'])
|
||||
expect(resolveText).toHaveBeenCalledWith(DESCRIPTIONS.http)
|
||||
})
|
||||
|
||||
it('shows a plugin provider’s description before the pointer to its own settings, and nothing for a provider without one', () => {
|
||||
const base = plugins()
|
||||
renderPage({
|
||||
...base,
|
||||
search: { ...base.search, options: [{ id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' }, missing: false }] },
|
||||
fetch: { ...base.fetch, selected: 'jina', kind: 'plugin', options: [{ id: 'jina', description: { en: 'Jina.', zh: '' }, missing: false }] },
|
||||
})
|
||||
expect(paragraphs(en.searchSection)).toEqual(['Exa 搜索', en.pluginProviderHint])
|
||||
// An empty translation shows no paragraph.
|
||||
expect(paragraphs(en.fetchSection)).toEqual([en.pluginProviderHint])
|
||||
})
|
||||
|
||||
it('closes an open menu without selecting on Escape', () => {
|
||||
const { actions } = renderPage()
|
||||
fireEvent.click(searchMenu())
|
||||
fireEvent.keyDown(screen.getAllByRole('menuitem')[0]!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menuitem')).toBeNull()
|
||||
expect(actions.selectProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names plugin providers by title or id, and marks a selection the Host does not register', () => {
|
||||
const base = shipped()
|
||||
renderPage({
|
||||
...base,
|
||||
search: {
|
||||
selected: 'perplexity', kind: 'none', auto: false,
|
||||
options: [...SEARCH_OPTIONS, { id: 'exa', title: 'Exa', missing: false }, { id: 'perplexity', missing: true }],
|
||||
},
|
||||
fetch: {
|
||||
selected: 'jina', kind: 'plugin', auto: false,
|
||||
options: [...FETCH_OPTIONS, { id: 'jina', missing: false }],
|
||||
},
|
||||
})
|
||||
expect(searchMenu().textContent).toBe('perplexity (not loaded)')
|
||||
expect(zh.providerMissing).toBe('{id}(未加载)')
|
||||
expect(fetchMenu().textContent).toBe('jina')
|
||||
expect(screen.getAllByText(en.pluginProviderHint)).toHaveLength(1)
|
||||
expect(screen.queryByText(en.credential)).toBeNull()
|
||||
fireEvent.click(searchMenu())
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent))
|
||||
.toEqual([en.providerDeepSeek, en.providerLegacy, 'Exa', 'perplexity (not loaded)'])
|
||||
})
|
||||
|
||||
it('explains a plugin Search provider and an unselected Fetch provider', () => {
|
||||
renderPage(plugins())
|
||||
expect(searchMenu().textContent).toBe('Exa')
|
||||
expect(fetchMenu().textContent).toBe(en.providerAuto)
|
||||
expect(screen.getByText(en.pluginProviderHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers automatic selection where the deployment composes no provider', () => {
|
||||
const base = shipped()
|
||||
const { actions } = renderPage({
|
||||
...base,
|
||||
search: { ...base.search, auto: true },
|
||||
fetch: { ...base.fetch, selected: '', kind: 'none', auto: true },
|
||||
})
|
||||
expect(fetchMenu().textContent).toBe(en.providerAuto)
|
||||
fireEvent.click(searchMenu())
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerAuto, en.providerDeepSeek, en.providerLegacy])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto }))
|
||||
expect(actions.selectProvider).toHaveBeenCalledWith('searchProvider', '')
|
||||
fireEvent.click(fetchMenu())
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto }))
|
||||
expect(actions.selectProvider).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a failed provider list read in each section and retries it', () => {
|
||||
const { actions } = renderPage({ ...shipped(), providersFailed: true })
|
||||
for (const name of [en.searchSection, en.fetchSection]) {
|
||||
expect(region(name).getByRole('status').textContent).toBe(`${en.providersFailed}${en.retry}`)
|
||||
fireEvent.click(region(name).getByRole('button', { name: en.retry }))
|
||||
}
|
||||
expect(zh.providersFailed).toBe('无法读取提供方列表,此菜单可能不完整')
|
||||
expect(actions.retryProviders).toHaveBeenCalledTimes(2)
|
||||
expect(searchMenu().textContent).toBe(en.providerDeepSeek)
|
||||
})
|
||||
|
||||
it('shows no provider list notice while the list read succeeds', () => {
|
||||
renderPage()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: en.retry })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the legacy key control usable while the settings document is read-only', () => {
|
||||
const { actions } = renderPage({ ...legacy(), writable: false })
|
||||
const key = screen.getByLabelText(en.credential)
|
||||
expect(key).toHaveProperty('disabled', false)
|
||||
expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true)
|
||||
expect(searchMenu()).toHaveProperty('disabled', true)
|
||||
fireEvent.change(key, { target: { value: 'ds-secret' } })
|
||||
expect(actions.edit).toHaveBeenCalledWith('legacyApiKey', 'ds-secret')
|
||||
})
|
||||
|
||||
it('disables the legacy key control when the reference itself is not writable', () => {
|
||||
renderPage(legacy({ configured: true, writable: false }))
|
||||
expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true)
|
||||
expect(screen.getByText(en.apiKeySet)).toBeTruthy()
|
||||
expect(searchMenu()).toHaveProperty('disabled', false)
|
||||
})
|
||||
|
||||
it('disables the legacy key control while a save is in flight', () => {
|
||||
const { store } = renderPage(legacy())
|
||||
expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', false)
|
||||
act(() => { store.set({ ...legacy(), saving: true }) })
|
||||
expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it.each([{ invalid: true }, { failed: true }])('locks every menu while an error must stay visible: %j', (state) => {
|
||||
renderPage({ ...apis(), ...state, selectionBlocked: true })
|
||||
expect(searchMenu()).toHaveProperty('disabled', true)
|
||||
expect(fetchMenu()).toHaveProperty('disabled', true)
|
||||
fireEvent.click(searchMenu())
|
||||
expect(screen.queryByRole('menuitem')).toBeNull()
|
||||
expect(searchMenu().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('closes an open menu and reports it collapsed when the menus lock', () => {
|
||||
const { store } = renderPage()
|
||||
fireEvent.click(searchMenu())
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(2)
|
||||
act(() => { store.set({ ...shipped(), invalid: true, selectionBlocked: true }) })
|
||||
expect(screen.queryByRole('menuitem')).toBeNull()
|
||||
expect(searchMenu().getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('explains the supported result count and blocks saving an invalid draft', () => {
|
||||
renderWebSearch({ useSearchApi: true, count: field('51', { invalid: true }), invalid: true, dirty: true })
|
||||
renderPage({
|
||||
...apis(), count: field('51', { invalid: true }), invalid: true, dirty: true, selectionBlocked: true,
|
||||
})
|
||||
expect(screen.getByText(en.invalidCount)).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.count).getAttribute('aria-invalid')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('edits the Search API endpoint and result count in Search API mode', () => {
|
||||
const actions = renderWebSearch({ useSearchApi: true, count: field('20', { overridden: true }) })
|
||||
expect(screen.queryByLabelText(en.maxUses)).toBeNull()
|
||||
expect(screen.getByLabelText(en.count)).toHaveProperty('value', '20')
|
||||
expect(screen.getByText(en.countHint)).toBeTruthy()
|
||||
expect(screen.getByText(en.searchApiBaseUrlHint)).toBeTruthy()
|
||||
expect(screen.queryByText(en.baseUrlHint)).toBeNull()
|
||||
expect(screen.getByLabelText(en.searchApiBaseUrl)).toHaveProperty('value', 'https://api.deepseek.com/v1/tools')
|
||||
fireEvent.change(screen.getByLabelText(en.searchApiBaseUrl), { target: { value: 'https://other.test' } })
|
||||
fireEvent.change(screen.getByLabelText(en.count), { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.reset }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: en.useSearchApi }))
|
||||
expect(actions.edit.mock.calls).toEqual([['baseURL', 'https://other.test'], ['count', '30']])
|
||||
expect(actions.resetField.mock.calls).toEqual([['count']])
|
||||
expect(actions.setSearchApi).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
/** The page stylesheet reads only theme tokens ui-theme defines, so no value silently falls back. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
const page = read('../src/client/WebSearchCard.module.css')
|
||||
/** The global sheets that define `--dsw-*` tokens: the design palette and the base scale. */
|
||||
const theme = ['design-platform.css', 'base.css'].map(name => read(`../../ui-theme/src/styles/${name}`)).join('\n')
|
||||
|
||||
describe('WebSearchCard stylesheet', () => {
|
||||
it('references only defined theme tokens', () => {
|
||||
const used = [...new Set([...page.matchAll(/var\((--dsw-[\w-]+)/g)].map(match => match[1]!))]
|
||||
expect(used.length).toBeGreaterThan(0)
|
||||
expect(used.filter(token => !theme.includes(`${token}:`))).toEqual([])
|
||||
})
|
||||
|
||||
it('separates the sections by spacing, not hairlines', () => {
|
||||
expect(page.match(/\.section \+ \.section \{[^}]*\}/)?.[0]).not.toContain('border')
|
||||
})
|
||||
|
||||
it('separates the provider menu block from the panel below it by spacing, not a hairline', () => {
|
||||
const field = page.match(/\.field \{[^}]*\}/)?.[0]
|
||||
expect(field).toContain('padding: 8px 0 12px;')
|
||||
expect(field).not.toContain('border')
|
||||
expect(page).not.toMatch(/\.field\s*\+/)
|
||||
})
|
||||
|
||||
it('sets the settings below the provider menu on a gray panel', () => {
|
||||
expect(page).toMatch(/\.panel \{[^}]*background: var\(--dsw-alias-bg-module-platform\);/)
|
||||
})
|
||||
|
||||
it('colors the provider list notice with the error state token', () => {
|
||||
expect(page).toMatch(/\.notice \{[^}]*color: var\(--dsw-alias-state-error-primary\);/)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../web/web"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ export const VIEWER_ENDPOINT_CLASSES = {
|
||||
'userQuestions/answer': 'owner-only', // owner answers a pending question
|
||||
'userQuestions/attachWait': 'owner-only', // owner answers a pending question
|
||||
'llm/discoverModels': 'owner-only', // owner discovers models
|
||||
'web/listProviders': 'owner-only', // owner web search and fetch settings
|
||||
'agentPresets/list': 'owner-only', // owner agent presets
|
||||
'agentPresets/read': 'owner-only', // owner agent presets
|
||||
'agentPresets/select': 'owner-only', // owner agent presets
|
||||
|
||||
@@ -4233,20 +4233,26 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'web',
|
||||
summary: 'The web access service.',
|
||||
description: 'The web access service. Registered as `ctx.web` (one instance per context).\n\nSelection semantics (resolved at execution time, never order-dependent):\n\n- A configured id that is registered and `available()` → that provider.\n- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.\n- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.\n- No id configured, exactly one registered usable provider → that provider.\n- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.\n- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.',
|
||||
description: 'The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`).\n\nSelection semantics (resolved at execution time, never order-dependent):\n\n- A configured id that is registered and `available()` → that provider.\n- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.\n- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.\n- No id configured, exactly one registered usable provider → that provider.\n- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.\n- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'registerSearchProvider(provider: WebSearchProvider): () => void',
|
||||
description: 'Register a search provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search. Returns a disposer; disposed with the calling fiber.',
|
||||
description: 'Register a search provider. `listProviders()` lists it until the registration is disposed, which happens with the calling fiber or through the returned disposer; registration and disposal each emit `web/providers-changed`. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search.',
|
||||
parameters: [{ name: 'provider', description: 'the provider; its `id` is the registry key.' }],
|
||||
returns: 'the disposer that unregisters the provider.',
|
||||
},
|
||||
{
|
||||
signature: 'registerFetchProvider(provider: WebFetchProvider): () => void',
|
||||
description: 'Register a fetch provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. Returns a disposer; disposed with the calling fiber.',
|
||||
description: 'Register a fetch provider, with the listing, disposal, and event behavior of WebRuntime.registerSearchProvider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch.',
|
||||
parameters: [{ name: 'provider', description: 'the provider; its `id` is the registry key.' }],
|
||||
returns: 'the disposer that unregisters the provider.',
|
||||
},
|
||||
{
|
||||
signature: '@Remote listProviders(): WebProviderList',
|
||||
description: 'List every registered search and fetch provider for provider pickers. Availability does not filter the lists.',
|
||||
parameters: [],
|
||||
returns: 'detached `{ id, title, description }` entries per capability, in registration order.',
|
||||
},
|
||||
{
|
||||
signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
|
||||
description: 'Run one search through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. The seam enforces `request.maxResults` on the result: if the provider over-returns, `sources[]` is truncated and `truncated` set.',
|
||||
@@ -5271,6 +5277,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
description: 'Ask composed answerers for structured user input. Return an answer to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.',
|
||||
parameters: [{ name: 'request', description: 'pending user-question request.' }],
|
||||
},
|
||||
{
|
||||
name: 'web/providers-changed',
|
||||
mode: 'emit',
|
||||
signature: '\'web/providers-changed\'(): void',
|
||||
summary: 'The set of registered search or fetch providers changed: one provider registered, or its registration was disposed.',
|
||||
description: 'The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
name: 'webserver/index-inject',
|
||||
mode: 'emit',
|
||||
@@ -9853,7 +9867,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebFetchProvider',
|
||||
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise<boolean>;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
|
||||
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise<boolean>;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebFetchRequest',
|
||||
@@ -9899,6 +9913,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebhookSourceId',
|
||||
declaration: 'export type WebhookSourceId = Branded<\'WebhookSourceId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'WebProviderInfo',
|
||||
declaration: 'export interface WebProviderInfo {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebProviderList',
|
||||
declaration: 'export interface WebProviderList {\n readonly search: readonly WebProviderInfo[];\n readonly fetch: readonly WebProviderInfo[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebResource',
|
||||
declaration: 'export type WebResource = (request: Request, peer?: PeerScope) => Promise<Response>;',
|
||||
@@ -9921,7 +9943,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'WebSearchProvider',
|
||||
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise<boolean>;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
|
||||
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise<boolean>;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchRequest',
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: b1a9453e08198023
|
||||
zh: 630e7f566ad4d9db
|
||||
/deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation:
|
||||
en: 534b344a98142d91
|
||||
zh: 1291b721c8fead84
|
||||
en: 06629c312d0cb860
|
||||
zh: f33b2773b0ab1d25
|
||||
/deepseek-ai-dsh-llm-deepseek-account/further-exploration:
|
||||
en: 30a69b9c66d89580
|
||||
zh: fcc026a6373c1bee
|
||||
|
||||
@@ -63,7 +63,7 @@ The settings namespace is the Cordis entry id, or the plugin name without an ent
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport, which the API-key route also uses; the cache lives in memory and is keyed by endpoint and a SHA-256 of the token.
|
||||
Account-token lookup and invalidation use the [shared credential helpers](../llm-deepseek/src/credential.ts); this plugin retains HTTP error classification. Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport, which the API-key route also uses; the cache lives in memory and is keyed by endpoint and a SHA-256 of the token.
|
||||
|
||||
A failed request the shared transport classifies as `QUOTA` is rewritten to `ACCOUNT_QUOTA` before it leaves this provider, covering an HTTP 402 response and an in-band SSE error alike. The shared transport and the `deepseek-official` route keep `QUOTA`, so the account route's top-up action never appears for an API-key or third-party failure.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ kind: "package-reference"
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表请求、缓存和目录解析来自共享传输包,API key 路由也使用它们;缓存只在内存中,以端点和 token 的 SHA-256 为键。
|
||||
账号 token 读取与作废使用[共享凭据函数](../llm-deepseek/src/credential.ts);本插件保留 HTTP 错误分类。注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表请求、缓存和目录解析来自共享传输包,API key 路由也使用它们;缓存只在内存中,以端点和 token 的 SHA-256 为键。
|
||||
|
||||
共享传输判定为 `QUOTA` 的失败请求离开本提供方前会被改写为 `ACCOUNT_QUOTA`,HTTP 402 响应与流内 SSE 错误都适用。共享传输与 `deepseek-official` 路由保留 `QUOTA`,因此账号路由的充值操作不会出现在 API Key 或第三方失败上。
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import type {} from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
||||
import {
|
||||
catalogModelInfo, createModelListingCache, refuseListedModel, registerDeepSeekProvider, requestModelListing, resolveAdapterOptions,
|
||||
resolveListedCatalog,
|
||||
resolveListedCatalog, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken,
|
||||
} from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { DeepSeekRequestAuth, ListedCatalog, ListingView, ModelListing, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { Config, plainAccountOptions, resolveAccountOptions } from './config.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
export { Config, plainAccountOptions, resolveAccountOptions } from './config.ts'
|
||||
export const name = 'llm-deepseek-account'
|
||||
export const inject = ['llm']
|
||||
|
||||
@@ -31,9 +31,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const settings = () => resolveAccountOptions(plainAccountOptions(config), environment)
|
||||
settings()
|
||||
const supported = resolveAdapterOptions({}).models
|
||||
const account = () => ctx.get('deepseekAccount')
|
||||
const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise<DeepSeekRequestAuth> => {
|
||||
const token = await account()?.resolveToken(connection.baseURL)
|
||||
const token = await resolveDeepSeekAccountToken(ctx, connection.baseURL)
|
||||
if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED')
|
||||
return {
|
||||
headers: { [TOKEN_HEADER]: token },
|
||||
@@ -44,15 +43,14 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
if (error.failure.status !== 401) return error
|
||||
const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error })
|
||||
try { await account()?.rejectToken(token) }
|
||||
catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ }
|
||||
await rejectDeepSeekAccountToken(ctx, token)
|
||||
return rejected
|
||||
},
|
||||
}
|
||||
}
|
||||
const catalog = createModelListingCache(ctx, name, PROVIDER, {
|
||||
settings: () => settings().catalog,
|
||||
credential: async url => account()?.resolveToken(url),
|
||||
credential: url => resolveDeepSeekAccountToken(ctx, url),
|
||||
load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal),
|
||||
})
|
||||
let loggedListing: ModelListing | undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user