From 0d2fbaad67707f95b3281a1e39340c606283246a Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:27:35 +0800 Subject: [PATCH] fix(session-controller,ui-model-selection,ui-settings): handle catalog and account setup failures --- apps/web/tests/default-model.e2e.ts | 6 +-- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 ++-- docs/event-producer-consumer.zh.md | 10 ++-- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 8 ++-- docs/subsystems/llm-streaming.zh.md | 8 ++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../api/session-controller/src/catalog.ts | 17 +++++-- packages/api/session-controller/src/index.ts | 3 +- packages/api/session-controller/src/types.ts | 1 + .../tests/session-models.host.spec.ts | 46 +++++++++++++++++- .../client/skeleton/ConversationContent.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 7 ++- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 4 +- .../src/client/ModelSelect.tsx | 7 +-- .../src/client/directory.ts | 8 ++-- .../ui-model-selection/src/client/index.ts | 5 +- .../ui-model-selection/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 48 ++++++++++++++++++- .../tests/expected/account-first.txt | 4 +- .../tests/model-select.client.spec.tsx | 21 ++++++-- .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 4 +- .../client/ui-settings-account/README.zh.md | 4 +- .../src/client/AccountMenu.tsx | 4 +- .../src/client/SignOutDialog.tsx | 4 +- .../ui-settings-account/src/client/index.ts | 22 ++++----- .../ui-settings-account/src/client/locales.ts | 2 + .../tests/account.client.spec.tsx | 6 +-- .../tests/apply.client.spec.ts | 26 ++++++++-- .../tests/expected/sign-out-unknown-en.txt | 1 + .../tests/expected/sign-out-unknown-zh.txt | 1 + .../tests/sign-out-dialog.client.spec.tsx | 6 +-- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 ++ .../client/ui-settings-models/README.zh.md | 4 ++ .../src/client/CustomProviderCard.tsx | 3 +- .../src/client/ProviderEditor.tsx | 3 +- .../src/client/operations.ts | 15 ++++-- .../ui-settings-models/src/client/store.ts | 6 ++- .../tests/components.client.spec.tsx | 23 +++++++++ .../tests/provider-form.client.spec.tsx | 24 ++++++++++ .../tests/store.client.spec.ts | 10 ++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 + packages/llm/llm/README.zh.md | 2 + packages/llm/llm/src/index.ts | 8 ++-- 63 files changed, 348 insertions(+), 123 deletions(-) create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt create mode 100644 packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 40b3911071..45975fe2df 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -132,10 +132,8 @@ describe('web e2e: the composer model switch is the default for later sessions', const box = page.locator('[data-composer-input]').first() await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true) - // What removing the provider on the Models page leaves behind: the saved - // default still names the route, and nothing serves it any more. - // `replace`, not `update`: a merge patch of `{providers: {}}` leaves every - // stored profile in place. + // Removing the selected provider preserves the other route and the saved + // selection. A merge update would retain the removed profile. await scaffold.ctx.settings.replace('llm-pi-ai', { providers: { [START_ROUTE]: { displayName: 'Origin Gateway', api: 'openai-completions', baseURL: 'https://gateway.origin.example/v1', models: [{ id: START_MODEL, name: 'Origin Large' }] }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d642a66c82..d1ac605308 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3e651fa4433f0d6bab0ba73cd85929ed45e9ee79 -architecture.zh.md: a30bb804c419f38e3f68a06c252e90e4993b9e42 +architecture.md: fd3e04f7287ce97628b162becf647284221c8f64 +architecture.zh.md: f5e3bf8a3a6746044d5fbd229997eeb21303576c diff --git a/docs/architecture.md b/docs/architecture.md index 3e651fa443..fd3e04f728 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -Immutable requests retain cancellation. `Agent.activeProvider` exposes current provider ownership. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests with live cancellation and reuses message-freeze evidence only for identities it fully froze; [agent-loop](../packages/core/agent-loop/README.md) owns request construction. `Agent.activeProvider` identifies the provider of the latest prepared request. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index a30bb804c4..f5e3bf8a3a 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 `Agent.activeProvider` 暴露当前提供方归属。 +循环发送不可变请求并保留实时取消能力,只对已完整冻结的消息对象身份复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。`Agent.activeProvider` 标识最近一次已准备请求的提供方。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e174fcd03e..ba7cde7178 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: c5535c610ed88f265efe5065e2ff3374030192d4 -event-producer-consumer.zh.md: b2e558f5e711a3cf3628f1b9da802c1d69693090 +event-producer-consumer.md: f8160835916b5f1e6e71583c2adf647e4d996316 +event-producer-consumer.zh.md: 7a17b855917b7cb35d0fe67a56a9040dcd615978 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c5535c610e..f816083591 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b2e558f5e7..7a17b85591 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:355`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:282`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:383`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:580`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:587`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ae6c4b51eb..d59f81da1a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1 -persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b +persistence-catalog.md: f71cb89c5494c77df8d036faddea811a110444e1 +persistence-catalog.zh.md: abd0280d094b9f24fea7d9abe59d2ad17af263c3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43b4f86125..f71cb89c54 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5304,7 +5304,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7195,7 +7195,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c655d97086..abd0280d09 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5306,7 +5306,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:381`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7197,7 +7197,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:385`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:386`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 428d512264..db3a0f152a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44144,7 +44144,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:380", + "packages/api/session-controller/src/types.ts:381", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +57727,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:385" + "packages/api/session-controller/src/types.ts:386" ] }, { diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 757474dd41..2a34dc3c29 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585 -llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369 +llm-streaming.md: 6ff93698cb877591fb85bc1a05d1dabc95b6561b +llm-streaming.zh.md: 400fcd5a96de0097ec827055fe6f96fc3eef0846 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 07fc6599bc..6ff93698cb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -837,8 +837,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1014,7 +1015,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 079a33599d..400fcd5a96 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -843,8 +843,9 @@ declare abstract class LlmAdapter { imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined; /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -1020,7 +1021,8 @@ fileRequestText(ref: FileAttachmentRef): string /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */ diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index b178f956cf..c7614d54c7 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 5f09de61395c3d729144203b77eeb299c217128e -README.zh.md: dfb5b97b5804b4512d17eab47866ee079fbc047d +README.md: 5b96af2040c95e6e9195d55207ead0e0acbdba12 +README.zh.md: 42961830f1731ad9ce3efe048ebde03490f80637 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 5f09de6139..5b96af2040 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -68,7 +68,7 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. -GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. Availability never substitutes another model or rewrites a Session selection. +GUI model selection and prompt admission require the exact provider/model pair in the current available catalog. An unavailable selection or a failed catalog query rejects with `session/model-unavailable` before delivery; admission checks again after attachment preparation with the same error code. `initializeDefaultModel(provider)` saves the first available model of the explicitly configured provider only before any user choice exists. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection. These catalog requirements belong to GUI admission; direct LLM calls can use uncatalogued models. ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index dfb5b97b58..42961830f1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -68,7 +68,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 -GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。不可用的选择在投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。可用性变化不会替换模型或改写会话选择。 +GUI 模型选择与提示词准入要求确切提供方/模型对出现在当前可用目录中。选择不可用或目录查询失败时,投递前以 `session/model-unavailable` 拒绝;附件准备完成后再次检查,并使用相同错误码。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存其显式配置的提供方的第一个可用模型。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。这些目录要求属于 GUI 准入;直接 LLM 调用可使用未列入目录的模型。 ----- diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts index df751d86ba..e66c6961cb 100644 --- a/packages/api/session-controller/src/catalog.ts +++ b/packages/api/session-controller/src/catalog.ts @@ -1,5 +1,7 @@ /** Shared projection of the live LLM registry into the browser model catalog. */ +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { LlmModelInfo } from '@deepseek-ai/dsh-llm' import type { Context } from '@deepseek-ai/cordis' import type { ModelCatalog, @@ -57,11 +59,12 @@ export async function buildModelCatalog( } } })) + const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0) return { default: { ...defaultSelection }, - routableProviders: catalog.flatMap(item => item.kind === 'group' && item.group.models.length > 0 ? [item.group.id] : []), - groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) - .filter(group => group.models.length > 0), + routableProviders: groups.map(group => group.id), + groups, failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), } } @@ -74,6 +77,12 @@ export async function buildModelCatalog( */ export async function modelAvailable(ctx: Context, selection: ModelSelection): Promise { if (!ctx.llm.listProviders().some(provider => provider.id === selection.provider)) return false - const models = await ctx.llm.listModels(selection.provider) + let models: readonly LlmModelInfo[] + try { models = await ctx.llm.listModels(selection.provider) } + catch (error) { + throw new RemoteError('session/model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: selection.provider, model: selection.model }) + } return models.some(model => model.id === selection.model) } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 6f51c15da4..2cc81e3097 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -279,7 +279,8 @@ export class SessionController extends TypertRemoteService { async initializeDefaultModel(provider: string): Promise { const catalog = await buildModelCatalog(this.ctx) const model = catalog.groups.find(group => group.id === provider)?.models[0] - if (model === undefined) throw new Error(`provider "${provider}" has no available models`) + if (model === undefined) throw new RemoteError('session/provider-models-unavailable', + `provider "${provider}" has no available models`, { provider }) await this.ctx.agentDefaultModel.initializeSelection({ provider, model: model.id, ...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) }, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c5b92ec0cc..e1021e8c0e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -187,6 +187,7 @@ export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'session/provider-models-unavailable': { readonly provider: string } 'session/model-unavailable': { readonly provider: string; readonly model: string } 'session/conflict': { readonly sessionId: SessionId diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index 26d71d839e..0994ed1474 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -360,7 +360,9 @@ describe('Web session model selection', () => { await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' }) await controller.initializeDefaultModel('deepseek-official') expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' }) - await expect(controller.initializeDefaultModel('empty')).rejects.toThrow('no available models') + await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({ + code: 'session/provider-models-unavailable', details: { provider: 'empty' }, + }) await ctx.fiber.dispose() }) @@ -612,6 +614,48 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it.each([1, 2])('reports catalog errors as model-unavailable at prompt check %s', async (check) => { + const { ctx, sessionId, agent } = await harness() + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', + }) + const list = vi.spyOn(ctx.llm, 'listModels') + if (check === 2) list.mockResolvedValueOnce([{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'Chat' }]) + list.mockRejectedValueOnce(new Error('credential storage offline')) + try { + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ type: 'text', text: 'hello' }], + }))).toMatchObject({ ok: false, error: { + code: 'session/model-unavailable', message: 'credential storage offline', + details: { provider: 'deepseek-official', model: 'deepseek-chat' }, + } }) + expect(list).toHaveBeenCalledTimes(check) + expect(followup).not.toHaveBeenCalled() + } finally { + list.mockRestore() + await ctx.fiber.dispose() + } + }) + + it('leaves uncatalogued adapters usable by core callers but unavailable to GUI selection', async () => { + const { ctx, sessionId } = await harness() + ctx.llm.registerAdapter(['uncatalogued'], new class extends LlmAdapter { + override async *stream(_options: GenerateOptions): AsyncIterable {} + }()) + try { + await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', + }) + expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) + .toMatchObject({ ok: false, error: { code: 'session/model-unavailable' } }) + } finally { + await ctx.fiber.dispose() + } + }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { const { ctx, sessionId } = await harness() const remote = createSessionTestRemote(ctx, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 42bc9724df..dc7e753c88 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -150,7 +150,7 @@ export function ConversationContent(props: ConversationContentProps) { // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the // user clears it. - ? { blocked: composerBlock, ...(hero ? { placeholder: t('placeholder.hero') } : {}) } + ? { blocked: composerBlock, placeholder: t(hero ? 'placeholder.hero' : 'placeholder.default') } : hero ? { placeholder: t('placeholder.hero') } : {}), }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..c7720a7db2 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -424,15 +424,14 @@ describe('ConversationRoot resident composer', () => { expect(dispatchCount()).toBe(before) }) - it('renders the composer inert with the blocker\u2019s own reason', () => { + it('keeps the normal placeholder while model availability blocks sending', () => { const b = mount(sessionSnapshotOf(), undefined, undefined, { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') - // One disabled composer with the blocker's placeholder, never a second - // tree: the DOM survives the block being raised and cleared. + // Blocking keeps the mounted composer and its normal placeholder. expect(box.getAttribute('aria-disabled')).toBe('true') - expect(box.getAttribute('data-placeholder')).toBe('select a model first') + expect(box.getAttribute('data-placeholder')).toBe(zh['placeholder.default']) fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index db4fe73951..496f41b644 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 3c73a42b34ad9075eaaab9e256767b7250ea0513 -README.zh.md: 87cccdeacde3183427a1ff08e1d1288e5cf170be +README.md: 25945f012b2c1f6cfc0f36203fac632c01a86101 +README.zh.md: 6c59586a08b7833f40491f61b5f57cd2b76e5735 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 3c73a42b34..25945f012b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -33,11 +33,11 @@ Mount this plugin alongside `ui-conversation` and the commands package; the comp ### Model and effort -Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first; other providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. +Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. ### Unroutable sessions -The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. +The composer blocks sending while the exact provider/model pair is absent from the available catalog or availability is loading or unknown. It keeps the normal input placeholder and an enabled model picker. Refreshes and refresh failures retain the last displayed selection and groups while sending remains blocked; retry revalidates availability. A Host reset clears that display. The account provider name follows the active locale in both selectors. Sign-out, credential removal, and provider or model removal refresh availability without replacing the saved choice with another model. Existing session logs remain unchanged. Only the current Client binding's directory can publish its composer block. Cleanup from an older binding preserves a replacement directory's block; without a replacement directory, cleanup removes the obsolete block. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 87cccdeacd..6c59586a08 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -33,11 +33,11 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示 ### 模型与推理强度 -模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号置顶,其他提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 +模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 ### 不可路由的会话 -确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 +确切提供方/模型对不在可用目录中,或可用性仍在加载或未知时,composer 禁止发送。输入框保留常规占位文案,模型选择器仍可操作。刷新及刷新失败期间保留上次显示的选择和分组,同时继续禁发;重试会重新确认可用性。Host 重置时清空这些显示。两个选择器中的账号提供方名称均随当前语言显示。退出账号、移除凭据、提供方或模型都会刷新可用性,不会用其他模型替换已保存的选择。既有会话日志保持不变。 只有当前 Client binding 的目录可以发布对应的 composer 阻塞块。旧 binding 的清理保留后继目录的阻塞块;若尚无后继目录,则移除已失效的阻塞块。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index 2223cd3c21..d209e23cc7 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -76,7 +76,8 @@ export function ModelSelect( const id = useId() const groups = useMemo(() => state.groups.toSorted((left, right) => - Number(right.id === 'deepseek-account') - Number(left.id === 'deepseek-account')), [state.groups]) + (left.id === 'deepseek-account' ? 0 : left.id === 'deepseek-official' ? 1 : 2) + - (right.id === 'deepseek-account' ? 0 : right.id === 'deepseek-official' ? 1 : 2)), [state.groups]) const choices = useMemo(() => groups.flatMap(group => group.models.map(model => ({ group, @@ -407,7 +408,7 @@ export function ModelSelect( )} {state.failures.map(failure => (
- {t('warning.groupLoad', { name: failure.name, message: failure.message })} + {t('warning.groupLoad', { name: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, message: failure.message })}
))} @@ -416,7 +417,7 @@ export function ModelSelect( const headingId = `${id}-${group.id}` return (
-
{group.name}
+
{group.id === 'deepseek-account' ? t('provider.account') : group.name}
{group.models.map((model) => { const selected = state.current?.provider === group.id && state.current.model === model.id return ( diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts index 162ad5db7d..355dfc7fda 100644 --- a/packages/client/ui-model-selection/src/client/directory.ts +++ b/packages/client/ui-model-selection/src/client/directory.ts @@ -15,7 +15,7 @@ import type { ModelCatalogDirectory } from './catalog.ts' /** Directory snapshot both entries render from. */ export interface ModelDirectoryState { - /** Available durable selection, or the available default for an unselected Session. */ + /** Last catalog-confirmed selection, retained for display during same-Host refreshes. */ current: ModelSelection | null /** Saved effort caption retained when the selected model is unavailable. */ retainedEffort?: string @@ -145,11 +145,11 @@ export class ModelDirectory { : reasoning?.efforts.find(level => level.id === effort)?.name ?? effort if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) { this.store.set({ - current: null, + current: catalog.value === null ? null : this.store.getSnapshot().current, ...retainedEffort === undefined ? {} : { retainedEffort }, routable: null, - groups: [], - failures: [], + groups: catalog.value?.groups ?? [], + failures: catalog.value?.failures ?? [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error, }) diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index 201ac8c732..12f81cec2f 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -67,12 +67,13 @@ function descriptionOf( function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): SelectOption[] { const rows: SelectOption[] = [] for (const group of directory.groups) { + const name = group.id === 'deepseek-account' ? t('provider.account') : group.name for (const model of group.models) { const description = descriptionOf(group.id, model, t) rows.push({ id: rowId(group.id, model.id), label: model.name, - detail: description !== undefined ? `${group.name} · ${description}` : group.name, + detail: description !== undefined ? `${name} · ${description}` : name, ...(directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id @@ -83,7 +84,7 @@ function optionsOf(directory: ModelDirectoryState, t: TranslateNS<'model'>): Sel for (const failure of directory.failures) { rows.push({ id: `failure/${failure.id}`, - label: failure.name, + label: failure.id === 'deepseek-account' ? t('provider.account') : failure.name, detail: t('option.loadError', { message: failure.message }), }) } diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index 2ab727b353..9d60b95926 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -10,6 +10,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'provider.account': 'DeepSeek 账号', 'command.label': '模型', 'command.description': '选择本会话使用的模型', 'option.loadError': '目录加载失败:{message}', @@ -39,6 +40,7 @@ export type ModelKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'provider.account': 'DeepSeek Account', 'command.label': 'Model', 'command.description': 'Select the model for this conversation', 'option.loadError': 'Catalog failed to load: {message}', diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index af7d3dee20..ffd02ee21b 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -73,16 +73,19 @@ async function bench(locale: 'zh' | 'en' = 'zh') { const projections = new Map>() // Whether the Host advertises available models for the current route. let routable = true + let catalogFailure = false + let groups = GROUPS let selectionFailure: RemoteError<'session/writer-held'> | undefined const sessionRemote = { modelCatalog: () => { calls.models += 1 + if (catalogFailure) return Promise.reject(new Error('catalog offline')) return Promise.resolve({ ok: true as const, value: { default: defaultSelection, routableProviders: routable ? ['deepseek-official'] : [], - groups: routable ? GROUPS : [], + groups: routable ? groups : [], failures: [], }, }) @@ -185,6 +188,8 @@ async function bench(locale: 'zh' | 'en' = 'zh') { setHostCurrent: (selection: ModelSelection) => { defaultSelection = selection }, setProjected: (id: SessionId, value: ModelSelectionProjection) => { projections.get(id)?.set(value) }, address: (id: SessionId) => { addressed.add(id) }, + setGroups: (next: typeof GROUPS) => { groups = next }, + setCatalogFailure: (next: boolean) => { catalogFailure = next }, setRoutable: (next: boolean) => { routable = next }, blockOf: (key: string) => blocks.get(sid(key)), } @@ -280,6 +285,19 @@ describe('ui-model-selection dual entry', () => { }) }) + it.each(['en', 'zh'] as const)('localizes account provider details in the %s model popup', async (locale) => { + const b = await bench(locale) + try { + b.setGroups([{ ...GROUPS[0]!, id: 'deepseek-account', name: 'DeepSeek Account' }]) + b.remote.emit('llm/adapters-updated', []) + b.mint('s1') + const options = await b.popup().options(projection('s1'), new AbortController().signal) + expect(options[0]?.detail).toContain(locale === 'zh' ? 'DeepSeek 账号' : 'DeepSeek Account') + } finally { + await b.ctx.fiber.dispose() + } + }) + it('both entries share one directory instance per session, isolated across sessions', async () => { const b = await bench() b.mint('a') @@ -330,7 +348,9 @@ describe('ui-model-selection dual entry', () => { next: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, }) expect(face.directory.getSnapshot()).toMatchObject({ - current: null, + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, + routable: null, status: 'loading', }) @@ -342,6 +362,30 @@ describe('ui-model-selection dual entry', () => { }) }) + it('retains the last catalog on refresh failure, blocks sending, and recovers on retry', async () => { + const b = await bench() + try { + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + b.setCatalogFailure(true) + b.remote.emit('credentials/record-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + groups: GROUPS, routable: null, status: 'error', error: 'catalog offline', + }) + }) + expect(b.blockOf('s1')).toBeDefined() + b.setCatalogFailure(false) + await b.ctx.modelDirectories.directoryFor(sid('s1')).load() + expect(face.directory.getSnapshot().routable).toBe(true) + expect(b.blockOf('s1')).toBeUndefined() + } finally { + await b.ctx.fiber.dispose() + } + }) + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { const b = await bench() const first = b.mint('s1') diff --git a/packages/client/ui-model-selection/tests/expected/account-first.txt b/packages/client/ui-model-selection/tests/expected/account-first.txt index 304c901c9f..6a8a6e2c70 100644 --- a/packages/client/ui-model-selection/tests/expected/account-first.txt +++ b/packages/client/ui-model-selection/tests/expected/account-first.txt @@ -1,8 +1,8 @@ deepseek-account-1 deepseek-account-2 -custom-1 -custom-2 deepseek-official-1 deepseek-official-2 +custom-1 +custom-2 another-1 another-2 diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 2d8df4fad0..feef74b1bd 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' // The seat's key domain is model ∪ common; the stub mirrors the real lookup @@ -463,7 +463,7 @@ it('shows the unselected model control with the inherited effort', async () => { }) -it('places account models first while preserving other provider and model order', async () => { +it('places account and official models before third-party models', async () => { const groups = ['custom', 'deepseek-official', 'deepseek-account', 'another'].map(id => ({ id, name: id, models: [1, 2].map(index => ({ id: `${id}-${index}`, name: `${id}-${index}` })), })) @@ -472,9 +472,22 @@ it('places account models first while preserving other provider and model order' fireEvent.click(screen.getByRole('button', { name: '请选择模型' })) const names = screen.getAllByRole('menuitemradio').map(row => row.textContent) expect(names).toEqual([ - 'deepseek-account-1', 'deepseek-account-2', 'custom-1', 'custom-2', - 'deepseek-official-1', 'deepseek-official-2', 'another-1', 'another-2', + 'deepseek-account-1', 'deepseek-account-2', 'deepseek-official-1', 'deepseek-official-2', + 'custom-1', 'custom-2', 'another-1', 'another-2', ]) expect(groups.map(group => group.id)).toEqual(['custom', 'deepseek-official', 'deepseek-account', 'another']) await expect(`${names.join('\n')}\n`).toMatchFileSnapshot('./expected/account-first.txt') }) + +it.each([en, zh])('localizes the account group while preserving external names', (copy) => { + const groups = ['deepseek-account', 'custom'].map(id => ({ + id, name: id === 'deepseek-account' ? 'DeepSeek Account' : 'My Gateway', + models: [{ id: 'model', name: 'Model' }], + })) + render( key in copy ? copy[key as keyof typeof copy] : key} />) + fireEvent.click(screen.getByRole('button', { name: copy['trigger.selectAria'] })) + expect(screen.getByRole('group', { name: copy['provider.account'] })).toBeTruthy() + expect(screen.getByRole('group', { name: 'My Gateway' })).toBeTruthy() +}) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index a9fcd71d5a..a3c8fbdf6f 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md -README.md: d54687d083897842518a0c95dfc84d39e985f3a1 -README.zh.md: c4dbcf234fb72e826ccd742b70740744e98e8fcb +README.md: 615d2ef3466fc748d7b8c65c65c356afdd4f3cf3 +README.zh.md: 39d8a94b2e8d98896b13b8c76f7e5ff6390b5d0d diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index d54687d083..615d2ef346 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -34,7 +34,7 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a The account card’s More account information link opens `https://platform.deepseek.com` in the system browser on Desktop or a new tab on Web. -Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed queries keep the menu available; failed sign-out keeps the dialog available for retry. +Sign out first queries running account-token tasks and opens a confirmation dialog. The warning describes interruption when such tasks exist; otherwise it explains that data is retained and the account can be signed in again. Cancel, close, and Escape dismiss without signing out. Failed impact queries still open confirmation with an explicit unknown-task warning; failed sign-out keeps the dialog available for retry. ## Understand the implementation @@ -74,3 +74,5 @@ No model request prefix changes. The [desktop login decision](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md) records cancellation and storage ownership. Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. + +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index c4dbcf234f..39d8a94b2e 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -34,7 +34,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl 账号卡片的“更多账号信息”链接在 Desktop 中通过系统浏览器打开 `https://platform.deepseek.com`,在 Web 中打开新标签页。 -退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。查询失败保留菜单;退登失败保留弹窗以便重试。 +退出登录先查询正在使用账号 token 的任务,再打开确认弹窗。有此类任务时提示执行将被打断,否则提示数据保留且可重新登录。取消、关闭和 Escape 均不退登。影响查询失败仍打开确认弹窗,并明确提示无法确认任务状态;退登失败保留弹窗以便重试。 ## 理解实现 @@ -74,3 +74,5 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl [桌面登录决策](../../../.agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.zh.md)记录取消和存储的职责。 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 + +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.tsx b/packages/client/ui-settings-account/src/client/AccountMenu.tsx index 6122728558..7570a13190 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.tsx +++ b/packages/client/ui-settings-account/src/client/AccountMenu.tsx @@ -27,12 +27,12 @@ export function AccountMenu({ const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [failed, setFailed] = useState(false) - const [signOutImpact, setSignOutImpact] = useState() + const [signOutImpact, setSignOutImpact] = useState() const requestSignOut = async () => { setBusy(true) setFailed(false) try { setSignOutImpact(await hasRunningAccountTasks()); setOpen(false) } - catch { setFailed(true) } + catch (_error) { setSignOutImpact('unknown'); setOpen(false) } finally { setBusy(false) } } return
diff --git a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx index f9b59e8ea8..d364f41ede 100644 --- a/packages/client/ui-settings-account/src/client/SignOutDialog.tsx +++ b/packages/client/ui-settings-account/src/client/SignOutDialog.tsx @@ -6,7 +6,7 @@ import css from './SignInDialog.module.css' /** @param props - task impact at opening, localized copy, and account actions. @returns sign-out confirmation. */ export function SignOutDialog({ running, signOut, close, t }: { - running: boolean + running: boolean | 'unknown' signOut: () => Promise close: () => void t: (key: AccountKey) => string @@ -29,7 +29,7 @@ export function SignOutDialog({ running, signOut, close, t }: {
-

{t(running ? 'signOutRunningDescription' : 'signOutDescription')}

+

{t(running === 'unknown' ? 'signOutUnknownDescription' : running ? 'signOutRunningDescription' : 'signOutDescription')}

{failed &&

{t('failed')}

}
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index da26bbd8d8..c718292cbf 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -72,19 +72,19 @@ export function apply(ctx: Context): void { for await (const frame of stream) { revision++ refreshing = undefined - let initializationFailed = false - if (frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded') { - try { - const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') - initializationFailed = !initialized.ok - } catch (_error) { - initializationFailed = true - } - } - publish({ ...snapshot, view: frame.value, details: undefined, failed: initializationFailed }) + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + publish({ ...snapshot, view: frame.value, details: undefined, failed: false }) browser.update(frame.value) frame.accept() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account') + if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() void refresh() } })().catch(() => { if (!stream.signal.aborted) publish({ ...snapshot, failed: true }) }) diff --git a/packages/client/ui-settings-account/src/client/locales.ts b/packages/client/ui-settings-account/src/client/locales.ts index b93c8e61c0..e3a07f3f8f 100644 --- a/packages/client/ui-settings-account/src/client/locales.ts +++ b/packages/client/ui-settings-account/src/client/locales.ts @@ -10,6 +10,7 @@ export const en = { settings: 'Settings', contactUs: 'Contact us', menu: 'Account menu', nav: 'Account', signedIn: 'Signed in to DeepSeek', signedOut: 'Not signed in', signIn: 'Sign in', signOut: 'Sign out', + signOutUnknownDescription: 'Could not check running tasks. Signing out may interrupt tasks using this account. Sign out now?', signOutDescription: 'Signing out will not delete any data. You can sign in to this account again.', signOutRunningDescription: 'Tasks are currently running. Signing out will interrupt them. Sign out now?', cancel: 'Cancel', open: 'Open browser', initializing: 'Starting sign in…', waiting: 'Continue in your browser', @@ -36,6 +37,7 @@ export const zh: Record = { settings: '设置', contactUs: '联系我们', menu: '账号菜单', nav: '账号与余额', signedIn: '已登录 DeepSeek', signedOut: '尚未登录', signIn: '登录', signOut: '退出登录', + signOutUnknownDescription: '暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?', signOutDescription: '退出登录不会丢失任何数据,你仍可以登录此账号。', signOutRunningDescription: '当前有进程正在执行中,点击“退出登录”,将会打断进程,是否即刻退出登录?', cancel: '取消', open: '打开浏览器', initializing: '正在发起登录…', waiting: '请在浏览器中继续', diff --git a/packages/client/ui-settings-account/tests/account.client.spec.tsx b/packages/client/ui-settings-account/tests/account.client.spec.tsx index 3757579a79..dbf81ece0e 100644 --- a/packages/client/ui-settings-account/tests/account.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/account.client.spec.tsx @@ -51,14 +51,14 @@ it('starts sign-in and disables cancellation during persistence', async () => { expect(screen.queryByRole('button', { name: en.signIn })).toBeNull() }) -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))('confirms sidebar sign-out with task impact $running', async ({ copy, running }) => { const signOut = vi.fn(() => Promise.resolve()) const openSettings = vi.fn() const operations = mount({ status: 'credential-stored', attempt: null }, copy) cleanup() const { AccountMenu } = await import('../src/client/AccountMenu.tsx') render( running} + hasRunningAccountTasks={async () => { if (running === 'unknown') throw new Error('offline'); return running }} useAccount={selector => selector(operations.hooks.account.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings} t={key => key in copy ? copy[key as AccountKey] : key} />) fireEvent.click(screen.getByRole('button', { name: copy.menu })) @@ -73,7 +73,7 @@ it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running } fireEvent.click(screen.getByRole('button', { name: copy.menu })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: copy.signOut })) }) expect(signOut).not.toHaveBeenCalled() - expect(screen.getByText(running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() + expect(screen.getByText(running === 'unknown' ? copy.signOutUnknownDescription : running ? copy.signOutRunningDescription : copy.signOutDescription)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(signOut).toHaveBeenCalledOnce() expect(screen.queryByRole('menu')).toBeNull() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 7f01fc5f6e..6a33cda9d4 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -4,13 +4,17 @@ import { expect, it, vi } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { +it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() const release = Promise.withResolvers() + const initialization = Promise.withResolvers() const initializeDefaultModel = vi.fn(async () => { + if (outcome === 'pending') await initialization.promise if (outcome === 'disconnected') throw new Error('connection lost') - return { ok: outcome === 'accepted', value: undefined } + return { ok: outcome === 'accepted' || outcome === 'pending', value: undefined } }) const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } const signedIn: AccountView = { @@ -38,7 +42,11 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m $stream: () => ({ signal: abort.signal, dispose: () => { abort.abort(); release.resolve(undefined) }, async *[Symbol.asyncIterator]() { - for (const value of frames) yield { value, accept: () => { if (value.status === 'signed-out') done.resolve(undefined) } } + for (const value of frames) yield { value, accept: () => { + const snapshot = operations?.hooks.account.getSnapshot() + observed.push({ status: snapshot?.view?.status, failed: snapshot?.failed }) + if (value.status === 'signed-out') done.resolve(undefined) + } } await release.promise }, }), @@ -48,8 +56,20 @@ it.each(['accepted', 'refused', 'disconnected'] as const)('initializes account m apply(ctx as never) await done.promise expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account') + expect(observed).toEqual([ + { status: 'credential-stored', failed: false }, + { status: 'credential-stored', failed: false }, + { status: 'signed-out', failed: false }, + ]) + if (outcome === 'accepted' || outcome === 'pending') expect(log).not.toHaveBeenCalled() + else expect(log).toHaveBeenCalledWith('[deepseek-account] default model initialization failed', { + reason: outcome === 'refused' ? 'refused' : 'disconnected', + }) expect(operations?.hooks.account.getSnapshot()).toMatchObject({ view: { status: 'signed-out' }, failed: false }) } finally { + initialization.resolve(undefined) + await Promise.resolve() for (const dispose of disposers.reverse()) dispose() + log.mockRestore() } }) diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt new file mode 100644 index 0000000000..d8b1fd328b --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-en.txt @@ -0,0 +1 @@ +Sign outCould not check running tasks. Signing out may interrupt tasks using this account. Sign out now?CancelSign out diff --git a/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt new file mode 100644 index 0000000000..3578b1f9bf --- /dev/null +++ b/packages/client/ui-settings-account/tests/expected/sign-out-unknown-zh.txt @@ -0,0 +1 @@ +退出登录暂时无法确认任务状态。退出登录可能会中断使用此账号的任务,是否继续?取消退出登录 diff --git a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx index 9ee95e897f..d2fbb29028 100644 --- a/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/sign-out-dialog.client.spec.tsx @@ -6,16 +6,16 @@ import { SignOutDialog } from '../src/client/SignOutDialog.tsx' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -function mount(running: boolean, copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { +function mount(running: boolean | 'unknown', copy: typeof en | typeof zh = en, signOut = vi.fn(async () => {})) { const close = vi.fn() render( copy[key]} />) return { signOut, close } } -it.each([en, zh].flatMap(copy => [false, true].map(running => ({ copy, running }))))( +it.each([en, zh].flatMap(copy => ([false, true, 'unknown'] as const).map(running => ({ copy, running }))))( 'confirms sign-out with localized task impact: $running', async ({ copy, running }) => { const props = mount(running, copy) await expect(`${screen.getByRole('dialog').textContent}\n`) - .toMatchFileSnapshot(`./expected/sign-out-${running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) + .toMatchFileSnapshot(`./expected/sign-out-${running === 'unknown' ? 'unknown' : running ? 'running' : 'idle'}-${copy === en ? 'en' : 'zh'}.txt`) expect(props.signOut).not.toHaveBeenCalled() await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.signOut })) }) expect(props.signOut).toHaveBeenCalledOnce() diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index c84f99abf1..3147eeab54 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: ccce6981a618fd2dfa451f1600ae19e443d77973 -README.zh.md: 0d2f64a1fe4583493b192f33f128f86157a65f8f +README.md: 289a0ec0b3603590dddcab848a79235ca4579bcf +README.zh.md: 10c61a464d48933a692a265b5d5b0812bf79cdbb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index ccce6981a6..289a0ec0b3 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -25,6 +25,10 @@ English | [中文](README.zh.md) ## Use this package +Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer. + +DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order. + Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another. A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0d2f64a1fe..10c61a464d 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -25,6 +25,10 @@ kind: "package-reference" ## 使用本包 +凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。 + +提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。 + 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index c84e72f743..38def6a132 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -178,7 +178,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // and the retry above now goes straight back to this write. if (stored !== undefined) return stored } - return operations.initializeModel(route.trim()) + void operations.initializeModel(route.trim()) + return undefined } const create = async (): Promise => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 93e1fa7d1e..a91cc0d744 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -288,8 +288,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { if (keyValue.length > 0) { const stored = await operations.storeCredential(keyRef, keyValue) if (stored !== undefined) return stored - const initialized = await operations.initializeModel(props.provider) - if (initialized !== undefined) return initialized + void operations.initializeModel(props.provider) } setKeyDraft('') return undefined diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts index 75d87ac821..071ab72454 100644 --- a/packages/client/ui-settings-models/src/client/operations.ts +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -32,8 +32,11 @@ export type ModelDiscoveryOutcome = /** The Host operations the Models page and its cards invoke. */ export interface ModelsOperations { - /** @param provider - newly configured provider. @returns refusal text, or undefined after initialization. */ - initializeModel(provider: string): Promise + /** + * @param provider - newly configured provider. + * @returns after optional initialization; failures are logged without changing the save outcome. + */ + initializeModel(provider: string): Promise /** * Read one credential reference's state. * @param ref - credential reference name. @@ -84,8 +87,12 @@ export interface ModelsOperations { export function createModelsOperations(ctx: ClientContext): ModelsOperations { return { initializeModel: async (provider) => { - const response = await ctx.remote.session.initializeDefaultModel(provider) - return response.ok ? undefined : response.error.message + try { + const response = await ctx.remote.session.initializeDefaultModel(provider) + if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' }) + } catch (_error) { + console.info('[models] default model initialization failed', { provider, reason: 'disconnected' }) + } }, describeCredential: async (ref) => { const response = await ctx.remote.credentials.describe([ref]) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index f3e948b79f..f693b5960d 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -37,7 +37,7 @@ export interface ProviderDirectoryEntry { * Join declared configurable providers with the currently registered routes. * @param registered - live provider routes in registration order. * @param directory - declared configurable providers in declaration order. - * @returns declared rows followed by live routes with no declaration. + * @returns account and official routes first, then other routes in their original order. */ export function joinProviderDirectory( registered: readonly LlmProviderInfo[], @@ -64,7 +64,9 @@ export function joinProviderDirectory( active: true, }) } - return rows + return rows.toSorted((left, right) => + (left.provider === 'deepseek-account' ? 0 : left.provider === 'deepseek-official' ? 1 : 2) + - (right.provider === 'deepseek-account' ? 0 : right.provider === 'deepseek-official' ? 1 : 2)) } /** One provider row the page renders. */ diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 88e43e599f..c1752b9204 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -1111,6 +1111,29 @@ describe('ModelsSection', () => { expect(baseURL.value).toBe('') }) + it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const pending = Promise.withResolvers() + const { ctx, set } = await mountDeepSeekCard() + const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel') + if (outcome === 'pending') initialize.mockReturnValue(pending.promise) + else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline')) + else initialize.mockResolvedValue({ ok: false, + error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) }) + try { + fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByText(en.apply)) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() } + initialize.mockRestore() + log.mockRestore() + } + }) + it('rejects an invalid draft before writing', async () => { const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index ae71cf0557..370027cdd7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -847,6 +847,30 @@ describe('hand-declared providers', () => { return { ...scripted, onClose } } + it('closes a saved custom provider when default initialization is refused', async () => { + const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + const { face, mutate, set, onClose } = mountCard() + const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel') + .mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable', + 'empty catalog', { provider: 'acme-gateway' }) }) + try { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway') + expect(screen.queryByText('empty catalog')).toBeNull() + } finally { + initialize.mockRestore() + log.mockRestore() + } + }) + it('writes the whole profile and the key under the derived reference', async () => { const { mutate, set, onClose } = mountCard() diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index d181edc12e..5a56062da4 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -16,6 +16,16 @@ it.each([false, true])('retains configuration diagnostics when the route is acti }]) }) +it('places account and official before third-party providers', () => { + const providers = ['custom', 'deepseek-official', 'deepseek-account', 'openai'] + const directory = providers.map(provider => ({ + provider, displayName: provider, settingsNs: 'fixture', settingsPath: [], + })) + expect(joinProviderDirectory([], directory).map(row => row.provider)) + .toEqual(['deepseek-account', 'deepseek-official', 'custom', 'openai']) + expect(directory.map(row => row.provider)).toEqual(providers) +}) + let nextRpc = 0 function ok(value: T): RpcResponse { return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5706f2a77d..f05c4ed4f6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1398,7 +1398,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async listModels(provider: string): Promise', - description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', + description: 'Discover models advertised by one registered provider. Catalog membership does not constrain core routing. Catalog-driven entry points may restrict selection and submission to the advertised models.', parameters: [{ name: 'provider', description: 'registered provider route to inspect.' }], returns: 'detached model metadata in adapter-preferred order.', }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index fcc298d024..8bfe705158 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 7ded16b5e1d8c87b924aeb02c8eda0a3033bd3ee -README.zh.md: de612f7b2153eab35e8a00db71aeb1bb92824eb1 +README.md: be2694c0f7cd1fd19b7f17cabb0e2598e995a065 +README.zh.md: e07afc9f9084c04bd0605de284e40c4f8c44b445 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7ded16b5e1..be2694c0f7 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -47,7 +47,7 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s filesApiTimeoutMs: 60000 ``` -A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and unlisted model ids still pass through as text-only routes. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. +A request selects the route with `provider: deepseek-official`; the model id passes through to the wire, so new DeepSeek models need no re-registration. Omitted `models` advertises the text- and image-capable `deepseek-flash` alongside the text-only `deepseek-v4-pro`, each with a 1,000,000-token context window. An explicit list replaces those defaults, and core calls still pass unlisted model ids through as text-only routes. GUI selection and submission require a catalog entry. Clients, including model discovery tools, can read the advisory entries through `ctx.llm.listModels('deepseek-official')`. Image-capable entries may set `imagePixelBudget` to a positive integer or `low`, and may set `imageMaxBytes`. An entry may declare `systemPromptUpdate: in-history` when its endpoint reads the latest `system` message at any position of `messages` as the complete effective system prompt; the adapter reports the mode on the resolved model and the prepared call, and the agent loop then appends a changed prompt after the cached history instead of rewriting the leading system message ([decision rule](../../core/agent-loop/README.md#understand-the-implementation)). The default `deepseek-flash` entry declares this mode; other models require an explicit `models` declaration, and any value other than `in-history` fails at load with `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present`. | Field | Default | Meaning | |---|---|---| diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index de612f7b21..e07afc9f90 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" filesApiTimeoutMs: 60000 ``` -请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,未列出的模型 id 仍作为纯文本路由原样通过。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 +请求用 `provider: deepseek-official` 选择路由;模型 id 原样传到协议,因此新增 DeepSeek 模型无需重新注册。省略 `models` 时公布支持文本和图像的 `deepseek-flash`,以及仅支持文本的 `deepseek-v4-pro`,各自的上下文窗口均为 1,000,000 token。显式列表会替换这些默认值,核心调用中未列出的模型 id 仍作为纯文本路由原样通过。GUI 选择与提交要求模型具有目录条目。包括模型发现工具在内的客户端可通过 `ctx.llm.listModels('deepseek-official')` 读取这些建议性条目。支持图片的条目可把 `imagePixelBudget` 设置为正整数或 `low`,也可以设置 `imageMaxBytes`。当端点把 `messages` 中任意位置最新的 `system` 消息读作完整的有效系统提示词时,条目可以声明 `systemPromptUpdate: in-history`;适配器会在已解析模型与已准备调用上报告该模式,agent loop(智能体循环)随后把变化后的提示词追加到已缓存历史之后,而不是改写开头的 system 消息([决策规则](../../core/agent-loop/README.zh.md#understand-the-implementation))。默认的 `deepseek-flash` 条目声明该模式;其他模型需通过 `models` 显式声明,`in-history` 以外的任何值都会在加载时以 `llm-deepseek: catalog model "" systemPromptUpdate must be "in-history" when present` 失败。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6fac0fb24a..0adf149bab 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3 -README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc +README.md: 1abdc8e332410ccd69066af6944bc4a32221635e +README.zh.md: 320a232c13fd145b65d53860c0cb1a4f7bfeb5a7 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index cdda682ceb..1abdc8e332 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,6 +25,8 @@ Use `@deepseek-ai/dsh-llm` to stream model calls through configured provider ada ## Use this package +`listModels` describes models offered by catalog-driven interfaces. Core resolution and streaming can still accept unlisted ids. The GUI requires catalog membership for selection and submission; adapters intended for GUI use must implement `listModels` and advertise their available models. The base implementation returns an empty list and therefore offers no GUI models. + Any composition that calls a model provider — an agent loop, a session-title generator, a compaction summarizer — streams its requests through this service. Mount it together with at least one provider adapter; the service itself has no configuration and no provider wire code. ### When to choose it diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 8c679ecc69..320a232c13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`listModels` 描述目录驱动界面提供的模型。核心解析与流式调用仍可接受未列出的 ID。GUI 的模型选择与提交要求模型出现在目录中;供 GUI 使用的适配器必须实现 `listModels`,公布其可用模型。基类实现返回空列表,因此不向 GUI 提供模型。 + 任何调用模型提供方的组合——agent loop(智能体循环)、会话标题生成器、压缩(compaction)摘要器——都会通过本服务流式发起请求。与至少一个提供方适配器一起挂载它;服务本身没有任何配置,也不包含提供方协议代码。 ### 何时选择 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 5f633619ff..64252cb643 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -235,8 +235,9 @@ export abstract class LlmAdapter { /** * List models this adapter can currently advertise for one owned provider. - * The result is advisory: an adapter may accept unlisted model ids, and - * consumers must not turn absence into request rejection. + * Core routing accepts unlisted model ids; catalog-driven entry points such + * as the GUI may require membership. Adapters used there must advertise + * their available models; the base empty catalog offers no GUI selection. * @param _provider - one provider route owned by this adapter. * @returns discoverable models in adapter-preferred order. */ @@ -689,7 +690,8 @@ export class LlmRuntime extends TypertRemoteService { /** * Discover models advertised by one registered provider. Catalog membership - * is advisory and never changes routing or request validation. + * does not constrain core routing. Catalog-driven entry points may restrict + * selection and submission to the advertised models. * @param provider - registered provider route to inspect. * @returns detached model metadata in adapter-preferred order. */