From 7f38c1b099aeef8d5ad3c1382b4b97496d6a21c9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 12:57:20 +0800 Subject: [PATCH 001/121] fix(llm): name the Flash catalog entry DeepSeek-V4.1-Flash --- .../expected/deepseek-messages-settings/cards.expected.md | 2 +- .../onboarding-deepseek-config/default-models.expected.md | 2 +- apps/web/tests/onboarding-deepseek-config.e2e.ts | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 ++-- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 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-deepseek/src/config.ts | 2 +- packages/llm/llm-deepseek/src/models.ts | 2 +- packages/llm/llm-deepseek/tests/adapter.spec.ts | 2 +- packages/llm/llm-deepseek/tests/runtime.spec.ts | 8 ++++---- 13 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 47440f7c24..271acfa0a2 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -29,7 +29,7 @@ - text: deepseek-flash - textbox "显示名称 1": - /placeholder: 显示名称 - - text: DeepSeek-V41-Flash + - text: DeepSeek-V4.1-Flash - button "模型选项 1" - button "删除模型 1" - textbox "模型 ID 2": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 803e82b6f0..a4dfae4aa1 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -29,7 +29,7 @@ - text: deepseek-flash - textbox "显示名称 1": - /placeholder: 显示名称 - - text: DeepSeek-V41-Flash + - text: DeepSeek-V4.1-Flash - button "模型选项 1" [expanded] - button "删除模型 1" - text: 上下文窗口 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index f3b3b48542..1df3a76770 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -208,7 +208,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash') - expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash') + expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V4.1-Flash') expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro') expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2) await settings.getByRole('button', { name: '模型选项 1' }).click() diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index a894043050..1f7bc3a77e 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -18,8 +18,8 @@ en: 3bd37b85139d2fa7 zh: ba86c807eb83866e /deepseek-ai-dsh-web-app/use-this-package/starting-the-web-gui: - en: b7ab01cfe9fed40f - zh: 23703ab7727c3ab5 + en: e3cac911f080d8ef + zh: 60749e9f5c39c1bc /deepseek-ai-dsh-web-app/use-this-package/configuration: en: dc9845451198ddc8 zh: 5e8de50c7e8a059a diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 9fd7cda152..b1233e1388 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -36,7 +36,7 @@ dsh --profile web --no-open --port 8080 After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself. -**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. +**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V4.1-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API. Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index e07e0556c1..d6981c62bc 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -36,7 +36,7 @@ dsh --profile web --no-open --port 8080 启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。 -**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 +**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V4.1-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。 已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。 diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index c1827535b0..677265edec 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -21,8 +21,8 @@ en: cbeb14a9a602c2c9 zh: c78c0d26c5791085 /deepseek-ai-dsh-llm-deepseek/use-this-package/minimal-configuration: - en: 2248932972465067 - zh: d1e4b7988d80d3b2 + en: cfc2de7ef728dbf9 + zh: 2ce1a406061a71aa /deepseek-ai-dsh-llm-deepseek/use-this-package/endpoint-and-wire-format: en: fce88d2ebad6296c zh: 0c351f26b472d521 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 2a95be42cd..acf981e614 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -58,7 +58,7 @@ A request selects the route with `provider: deepseek-official`; the model id pas | `reasoningEffort` | `high` | Default effort: `off`, `low`, `high`, or `max` | | `maxTokens` | `256,000` | Per-request output cap; a model's own cap and explicit request values win | | `defaultContextWindow` | `1,000,000` | Capacity fallback for models without an exact value | -| `models` | V41 Flash + V4 Pro | Advisory catalog shown by discovery consumers | +| `models` | V4.1 Flash + V4 Pro | Advisory catalog shown by discovery consumers | | `streamIdleTimeoutMs` | `300,000` | Maximum provider idle time per outstanding stream read | | `maxRequestFilesBytes` | `128 MiB` | File-mode request-image byte budget; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` | | `maxInlineRequestImageBytes` | `20 MiB` | Independent base64 fallback high watermark | diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 5450bea35b..3e61069be8 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" | `reasoningEffort` | `high` | 默认强度:`off`、`low`、`high` 或 `max` | | `maxTokens` | `256,000` | 单次请求输出上限;模型自身上限与显式请求值优先 | | `defaultContextWindow` | `1,000,000` | 无精确值模型的容量回退 | -| `models` | V41 Flash + V4 Pro | 供发现消费方查看的建议性目录 | +| `models` | V4.1 Flash + V4 Pro | 供发现消费方查看的建议性目录 | | `streamIdleTimeoutMs` | `300,000` | 单次流读取未完成的最大提供方空闲时间 | | `maxRequestFilesBytes` | `128 MiB` | file 模式请求图片字节预算,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 | | `maxInlineRequestImageBytes` | `20 MiB` | 独立的 base64 回退高水位 | diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index 5996645b7e..0bb302fa29 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -26,7 +26,7 @@ export interface Config { maxTokens: Volatile /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */ defaultContextWindow: Volatile - /** Advisory models shown by discovery consumers; defaults to V41 Flash and V4 Pro. */ + /** Advisory models shown by discovery consumers; defaults to V4.1 Flash and V4 Pro. */ models: Volatile /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs: Volatile diff --git a/packages/llm/llm-deepseek/src/models.ts b/packages/llm/llm-deepseek/src/models.ts index 88b2972128..15d0c78253 100644 --- a/packages/llm/llm-deepseek/src/models.ts +++ b/packages/llm/llm-deepseek/src/models.ts @@ -6,7 +6,7 @@ import type { DeepSeekCatalogModel } from './types.ts' export const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-flash', - name: 'DeepSeek-V41-Flash', + name: 'DeepSeek-V4.1-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 8b9f4d4e81..205869802c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -141,7 +141,7 @@ describe('direct Messages HTTP', () => { expect(llm.providerInfo('deepseek-official')).toEqual({ id: 'deepseek-official', name: 'DeepSeek' }) expect(await llm.listModels('deepseek-official')).toEqual([]) expect(await llm.resolveModel('deepseek-official', 'deepseek-flash')).toMatchObject({ - name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', + name: 'DeepSeek-V4.1-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', }) expect(await llm.resolveModel('deepseek-official', MODEL)).toMatchObject({ id: MODEL }) expect(llm.imageRequestPricing('deepseek-official', MODEL)).toBeDefined() diff --git a/packages/llm/llm-deepseek/tests/runtime.spec.ts b/packages/llm/llm-deepseek/tests/runtime.spec.ts index 4c505e1fe2..7b324f9a63 100644 --- a/packages/llm/llm-deepseek/tests/runtime.spec.ts +++ b/packages/llm/llm-deepseek/tests/runtime.spec.ts @@ -1737,7 +1737,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', inputModalities: ['text', 'image'] }, { provider: 'deepseek-official', id: 'deepseek-v4-pro', @@ -1750,7 +1750,7 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ provider: 'deepseek-official', id: 'deepseek-flash', - name: 'DeepSeek-V41-Flash', + name: 'DeepSeek-V4.1-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', context: { contextWindow: 1_000_000 }, @@ -1865,7 +1865,7 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmRuntime) LlmDeepSeek.apply(ctx, LlmDeepSeek.Config({ baseURL: 'http://127.0.0.1:1' })) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'] }, + { provider: 'deepseek-official', id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', inputModalities: ['text', 'image'] }, { provider: 'deepseek-official', id: 'deepseek-v4-pro', @@ -2257,7 +2257,7 @@ describe('plugin registration and config', () => { const adapter = adapterOf() expect(adapter).toBeInstanceOf(DeepSeekAdapter) await expect(adapter.listModels('deepseek-official')).resolves.toEqual([]) - await expect(adapter.resolveModel('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'DeepSeek-V41-Flash' }) + await expect(adapter.resolveModel('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'DeepSeek-V4.1-Flash' }) }) it('resolves connection facts and the credential exactly once per stream call', async () => { From 20f99ffb0c6d7840abdfa8c0baffed68a05f463b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 12:59:31 +0800 Subject: [PATCH 002/121] feat(llm): let DeepSeek providers await their connection and refuse models before I/O --- packages/llm/llm-deepseek/src/adapter.ts | 19 ++++-- packages/llm/llm-deepseek/src/host.ts | 2 +- packages/llm/llm-deepseek/src/types.ts | 8 +++ .../llm-deepseek/tests/provider-hooks.spec.ts | 68 +++++++++++++++++++ 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/provider-hooks.spec.ts diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 96171e41a8..402617ff55 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -34,18 +34,25 @@ export class DeepSeekAdapter extends LlmAdapt override async listModels(provider: string) { return this.dependencies.discoverModels?.(provider) ?? [] } - override resolveModel(provider: string, model: string, _signal?: AbortSignal) { - return Promise.resolve(modelInfo(this.dependencies.options(), provider, model)) + override async resolveModel(provider: string, model: string, signal?: AbortSignal) { + return modelInfo(await this.connection(signal), provider, model) } override imageRequestPricing(_provider: string, model: string) { return imagePricing(this.dependencies.options(), model, this.imageAccess) } - override prepareCall(provider: string, model: string, _signal?: AbortSignal): Promise { - const connection = this.dependencies.options() - return Promise.resolve({ model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) }) + override async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise { + const connection = await this.connection(signal) + this.dependencies.admit?.(connection, model) + return { model: modelInfo(connection, provider, model), stream: options => this.generate(options, connection) } } stream(options: GenerateOptions): AsyncIterable { - return this.generate(options, this.dependencies.options()) + const connection = this.dependencies.options() + this.dependencies.admit?.(connection, options.model) + return this.generate(options, connection) + } + + private connection(signal: AbortSignal | undefined): Promise { + return this.dependencies.connect?.(signal) ?? Promise.resolve(this.dependencies.options()) } private async * generate(options: GenerateOptions, connection: C): AsyncGenerator { diff --git a/packages/llm/llm-deepseek/src/host.ts b/packages/llm/llm-deepseek/src/host.ts index a98fecbec5..3af3f6bbd0 100644 --- a/packages/llm/llm-deepseek/src/host.ts +++ b/packages/llm/llm-deepseek/src/host.ts @@ -17,7 +17,7 @@ import type { DeepSeekAdapterOptions, DeepSeekConnectionOptions } from './types. */ export function registerDeepSeekProvider( ctx: Context, provider: string, dependencies: Pick, - 'options' | 'resolveAuth' | 'providerName' | 'discoverModels'>): void { + 'options' | 'resolveAuth' | 'providerName' | 'discoverModels' | 'connect' | 'admit'>): void { ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) let userId: AnonymousUserId | undefined const adapter = new DeepSeekAdapter({ diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index ee8a38f420..49dd96c9fd 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -99,6 +99,14 @@ export interface DeepSeekAdapterOptions Promise /** Current validated connection facts; called once per operation. */ options: () => Connection + /** + * Resolve the connection for one exact-model lookup or prepared call, for + * example after a provider-owned catalog load; must settle promptly after + * `signal` aborts. Omission uses {@link options}. + */ + connect?: (signal?: AbortSignal) => Promise + /** Refuse a model before any network I/O by throwing an `LlmError`; omission admits every model. */ + admit?: (connection: Connection, model: string) => void /** Resolve authentication from this request's connection snapshot; never re-read the endpoint. */ resolveAuth: (connection: Connection) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ diff --git a/packages/llm/llm-deepseek/tests/provider-hooks.spec.ts b/packages/llm/llm-deepseek/tests/provider-hooks.spec.ts new file mode 100644 index 0000000000..90a2d6f502 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/provider-hooks.spec.ts @@ -0,0 +1,68 @@ +/** Provider-owned connection readiness and model admission on the shared DeepSeek adapter. */ +import { afterEach, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { LlmError } from '@deepseek-ai/dsh-llm' +import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { DeepSeekAdapter, registerDeepSeekProvider, resolveAdapterOptions } from '../src/index.ts' +import type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from '../src/index.ts' +import { options as request, prepareExtensions } from './helpers.ts' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +const endpoint = (models: DeepSeekCatalogModel[] = []) => resolveAdapterOptions({ baseURL: 'http://127.0.0.1:1', models }) + +async function route(hooks: Pick) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin({ + name: 'fixture-route', + inject: ['llm'], + apply: (child: Context) => { + registerDeepSeekProvider(child, 'fixture-route', { + options: () => endpoint(), resolveAuth: () => Promise.resolve({ headers: {} }), ...hooks, + }) + }, + }) + return ctx +} + +it('resolves exact models and prepared calls from the asynchronous connection', async () => { + const signals: Array = [] + const ctx = await route({ + connect: (signal) => { + signals.push(signal) + return Promise.resolve(endpoint([{ id: 'remote', contextWindow: 42 }])) + }, + }) + await expect(ctx.llm.resolveModelInfo('fixture-route', 'remote')).resolves.toMatchObject({ context: { contextWindow: 42 } }) + const controller = new AbortController() + await expect(ctx.llm.prepareCall({ provider: 'fixture-route', model: 'remote' }, controller.signal)) + .resolves.toMatchObject({ context: { contextWindow: 42 } }) + expect(signals).toContain(controller.signal) +}) + +it('refuses a model before a prepared call exists', async () => { + const ctx = await route({ + admit: (_connection, model) => { + if (model === 'blocked') throw new LlmError('blocked by fixture', 'CLIENT_UPGRADE_REQUIRED') + }, + }) + await expect(ctx.llm.prepareCall({ provider: 'fixture-route', model: 'blocked' })) + .rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED' }) + await expect(ctx.llm.prepareCall({ provider: 'fixture-route', model: 'open' })).resolves.toBeDefined() +}) + +it('refuses a model on a direct adapter stream', () => { + const adapter = new DeepSeekAdapter({ + options: () => endpoint(), + resolveAuth: () => Promise.resolve({ headers: {} }), + resolveUserId: () => 'fixture-user' as AnonymousUserId, + prepareExtensions, + admit: () => { throw new LlmError('blocked by fixture', 'MODEL_UNSUPPORTED') }, + }) + expect(() => adapter.stream(request({ provider: 'fixture-route', model: 'blocked' }))).toThrow(/blocked by fixture/) +}) From 4082eb3ceb360dedc20e110ce53a11377c617583 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:02:47 +0800 Subject: [PATCH 003/121] feat(llm): configure the DeepSeek account listing endpoint and model overrides --- docs/config-catalog.md | 21 ++- docs/config-catalog.zh.md | 21 ++- .../llm/llm-deepseek-account/package.json | 13 +- .../llm/llm-deepseek-account/src/config.ts | 151 +++++++++++++++++- .../llm/llm-deepseek-account/src/index.ts | 6 +- .../llm-deepseek-account/tests/config.spec.ts | 48 ++++++ .../tests/provider.spec.ts | 6 +- .../llm/llm-deepseek-account/tsconfig.json | 12 ++ packages/llm/llm-deepseek/src/config.ts | 15 +- packages/llm/llm-deepseek/src/index.ts | 2 +- pnpm-lock.yaml | 36 +++-- 11 files changed, 294 insertions(+), 37 deletions(-) create mode 100644 packages/llm/llm-deepseek-account/tests/config.spec.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 67b491dd73..8c1fae51bb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1547,12 +1547,27 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Model-listing endpoint; defaults to `/models` at the Messages `baseURL` origin, which it must share. */ + modelsURL: Volatile + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and this build's model knowledge. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6b8acdfd07..9fe80eaca6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1549,12 +1549,27 @@ export interface Config { ## `@deepseek-ai/dsh-llm-deepseek-account` - `inject`: `llm` -- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) -- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:5`](../packages/llm/llm-deepseek-account/src/config.ts) +- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`) +- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts) ```ts config-catalog /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig +export interface Config extends Omit { + /** Model-listing endpoint; defaults to `/models` at the Messages `baseURL` origin, which it must share. */ + modelsURL: Volatile + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and this build's model knowledge. */ + modelOverrides: Volatile> +} + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit ``` diff --git a/packages/llm/llm-deepseek-account/package.json b/packages/llm/llm-deepseek-account/package.json index 96166d4c33..57844578e7 100644 --- a/packages/llm/llm-deepseek-account/package.json +++ b/packages/llm/llm-deepseek-account/package.json @@ -26,13 +26,20 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "workspace:~", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", + "semver": "^7.8.5" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", - "@deepseek-ai/cordis-plugin-loader": "workspace:~" + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-timeout": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -41,7 +48,9 @@ "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/cordis-plugin-loader": "workspace:~", - "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*" + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@types/semver": "^7.8.0" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-deepseek-account": { diff --git a/packages/llm/llm-deepseek-account/src/config.ts b/packages/llm/llm-deepseek-account/src/config.ts index 08c14bdf19..a4ba75cff3 100644 --- a/packages/llm/llm-deepseek-account/src/config.ts +++ b/packages/llm/llm-deepseek-account/src/config.ts @@ -1,6 +1,149 @@ -/** Account providers expose protocol settings without an API-key reference. */ -import { Config as ProtocolConfig } from '@deepseek-ai/dsh-llm-deepseek' +/** Account route configuration: Messages settings, the model-listing endpoint, and per-model overrides. */ +import type { Volatile } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { isVolatile } from '@deepseek-ai/cosmokit' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { catalogModelFields, deepSeekConfigFields, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { Config as ProtocolConfig, DeepSeekCatalogModel, Options as ProtocolOptions, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' + +/** Default deadline of one model-listing request and of a first-use wait. */ +export const DEFAULT_MODELS_TIMEOUT_MS = 5_000 +/** Default listing age after which the next access refreshes it in the background. */ +export const DEFAULT_MODELS_MAX_AGE_MS = 600_000 + +/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */ +export type ModelOverride = Omit /** Account route configuration; authentication comes exclusively from the account service. */ -export type Config = ProtocolConfig -export const Config = ProtocolConfig +export interface Config extends Omit { + /** Model-listing endpoint; defaults to `/models` at the Messages `baseURL` origin, which it must share. */ + modelsURL: Volatile + /** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */ + modelsTimeoutMs: Volatile + /** + * Listing age after which the next access refreshes it in the background; + * a failed load retries after the same age (default ten minutes). + */ + modelsMaxAgeMs: Volatile + /** Field overrides by model id, layered over the account listing and this build's model knowledge. */ + modelOverrides: Volatile> +} + +const { models: _protocolCatalog, ...protocolFields } = deepSeekConfigFields + +const modelOverride: z = z.object({ + ...catalogModelFields, + // No default, unlike a models entry: an absent list materializes as [], which reads as inherited. + inputModalities: z.array(z.union(['text', 'image'] as const)), +}) + +export const Config = z.object({ + ...protocolFields, + modelsURL: z.string().volatile(), + modelsTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MODELS_TIMEOUT_MS).volatile(), + modelsMaxAgeMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MODELS_MAX_AGE_MS).volatile(), + modelOverrides: z.dict(modelOverride).volatile(), +}) + +/** Plain account options accepted by {@link resolveAccountOptions}. */ +export type AccountOptions = Omit & { + modelsURL?: string + modelsTimeoutMs?: number + modelsMaxAgeMs?: number + modelOverrides?: Record +} + +/** Model-listing endpoint and cache timing of one configuration generation. */ +export interface CatalogSettings { + /** Listing endpoint; it always shares the Messages origin. */ + readonly url: string + /** Deadline of one listing request. */ + readonly timeoutMs: number + /** Listing age after which the next access refreshes it. */ + readonly maxAgeMs: number +} + +/** One validated configuration generation of the account route. */ +export interface ResolvedAccountOptions { + /** Messages settings; request resolution replaces `models` with the resolved account catalog. */ + readonly protocol: ResolvedDeepSeekOptions + /** Listing endpoint and cache timing. */ + readonly catalog: CatalogSettings + /** Overrides by model id, holding only the fields each one declares. */ + readonly modelOverrides: Readonly> +} + +/** + * Read the current value behind every reference of a validated Config. + * @param config - parsed plugin Config. + * @returns plain options for {@link resolveAccountOptions}. + */ +export function plainAccountOptions(config: Config): AccountOptions { + return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, isVolatile(value) ? value.get() : value])) +} + +function listingEndpoint(explicit: string | undefined, baseURL: string): string { + const messages = new URL(baseURL) + let url: URL + try { + url = explicit === undefined ? new URL('/models', messages.origin) : new URL(explicit) + } catch (error) { + throw new Error('llm-deepseek-account: modelsURL must be an absolute HTTP(S) URL', { cause: error }) + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('llm-deepseek-account: modelsURL must be an HTTP(S) URL without credentials, query, or fragment') + } + if (url.origin !== messages.origin) { + throw new Error('llm-deepseek-account: modelsURL must share the Messages baseURL origin, the only origin that receives the account token') + } + return url.href +} + +function resolveOverride(id: string, override: ModelOverride): ModelOverride { + if (id.length === 0) throw new Error('llm-deepseek-account: modelOverrides keys must be non-empty model ids') + if (Object.hasOwn(override, 'id')) throw new Error(`llm-deepseek-account: modelOverrides.${id} sets "id", which is its key`) + const declared: ModelOverride = {} + for (const [key, value] of Object.entries(override)) { + if (value === undefined || (Array.isArray(value) && value.length === 0)) continue + Reflect.set(declared, key, value) + } + try { + // Cross-field rules also involve the entry an override meets once a listing loads, + // so an image-capable stand-in checks each declared field here. + resolveAdapterOptions({ models: [{ inputModalities: ['text', 'image'], ...declared, id }] }) + } catch (error) { + throw new Error(`llm-deepseek-account: modelOverrides.${id} is invalid: ${String(error)}`, { cause: error }) + } + return declared +} + +/** + * The one explicit resolve step from raw account settings to validated facts. + * Settings updates can reach it without schema validation, so every bound is judged here. + * @param options - plain account options. + * @param environment - this run's environment layers, or `undefined` outside the product CLI. + * @returns Messages settings, listing settings, and validated overrides. + */ +export function resolveAccountOptions(options: AccountOptions, environment?: LaunchEnvironmentSnapshot): ResolvedAccountOptions { + if (Object.hasOwn(options, 'models')) { + throw new Error('llm-deepseek-account: models is no longer configurable; the account /models listing supplies the catalog. ' + + 'Remove models from this entry and adjust single models with modelOverrides.') + } + const { + modelsURL, modelsTimeoutMs = DEFAULT_MODELS_TIMEOUT_MS, modelsMaxAgeMs = DEFAULT_MODELS_MAX_AGE_MS, + modelOverrides = {}, ...protocolOptions + } = options + if (!Number.isFinite(modelsTimeoutMs) || modelsTimeoutMs <= 0 || modelsTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new Error(`llm-deepseek-account: modelsTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isSafeInteger(modelsMaxAgeMs) || modelsMaxAgeMs < 0) { + throw new Error('llm-deepseek-account: modelsMaxAgeMs must be a non-negative safe integer') + } + const protocol = resolveAdapterOptions(protocolOptions, environment) + return { + protocol, + catalog: { url: listingEndpoint(modelsURL, protocol.baseURL), timeoutMs: modelsTimeoutMs, maxAgeMs: modelsMaxAgeMs }, + modelOverrides: Object.fromEntries(Object.entries(modelOverrides).map(([id, override]) => [id, resolveOverride(id, override)])), + } +} diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 847a792cd2..864b0dd6da 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -4,10 +4,10 @@ import type { Context } from '@deepseek-ai/cordis' import { LlmError } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { plainOptions, resolveAdapterOptions, registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' +import { registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' import type { DeepSeekRequestAuth, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' -import { Config } from './config.ts' +import { Config, plainAccountOptions, resolveAccountOptions } from './config.ts' export { Config } from './config.ts' export const name = 'llm-deepseek-account' export const inject = ['llm'] @@ -15,7 +15,7 @@ export const inject = ['llm'] const PROVIDER = 'deepseek-account' export function apply(ctx: Context, config: Config): void { - const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx)) + const options = () => resolveAccountOptions(plainAccountOptions(config), launchEnvironmentOf(ctx)).protocol options() const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise => { const account = ctx.get('deepseekAccount') diff --git a/packages/llm/llm-deepseek-account/tests/config.spec.ts b/packages/llm/llm-deepseek-account/tests/config.spec.ts new file mode 100644 index 0000000000..fad2ac209b --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/config.spec.ts @@ -0,0 +1,48 @@ +/** Account configuration resolves the listing endpoint, cache timing, and model overrides in one step. */ +import { expect, it } from 'vitest' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { Config, plainAccountOptions, resolveAccountOptions } from '../src/config.ts' + +it('derives the listing endpoint from the Messages origin and applies the cache defaults', () => { + expect(resolveAccountOptions({}).catalog).toEqual({ url: 'https://api.deepseek.com/models', timeoutMs: 5_000, maxAgeMs: 600_000 }) + expect(resolveAccountOptions({ baseURL: 'http://127.0.0.1:9/anthropic' }).catalog.url).toBe('http://127.0.0.1:9/models') + expect(resolveAccountOptions({ baseURL: 'http://127.0.0.1:9/anthropic', modelsURL: 'http://127.0.0.1:9/v2/models' }).catalog.url) + .toBe('http://127.0.0.1:9/v2/models') +}) + +it.each([ + [{ modelsURL: 'models' }, /modelsURL must be an absolute HTTP\(S\) URL/], + [{ modelsURL: 'ftp://api.deepseek.com/models' }, /without credentials, query, or fragment/], + [{ modelsURL: 'https://api.deepseek.com/models?all=1' }, /without credentials, query, or fragment/], + [{ modelsURL: 'https://other.example/models' }, /must share the Messages baseURL origin/], + [{ modelsTimeoutMs: 0 }, /modelsTimeoutMs must be a positive finite number/], + [{ modelsTimeoutMs: MAX_TIMER_DELAY_MS + 1 }, /modelsTimeoutMs must be a positive finite number/], + [{ modelsMaxAgeMs: -1 }, /modelsMaxAgeMs must be a non-negative safe integer/], + [{ modelsMaxAgeMs: 1.5 }, /modelsMaxAgeMs must be a non-negative safe integer/], + [{ models: [] }, /models is no longer configurable/], + [{ modelOverrides: { '': {} } }, /modelOverrides keys must be non-empty model ids/], + [{ modelOverrides: { 'deepseek-flash': { id: 'other' } } }, /modelOverrides\.deepseek-flash sets "id"/], + [{ modelOverrides: { 'deepseek-flash': { contextWindow: 0 } } }, /modelOverrides\.deepseek-flash is invalid: .*contextWindow must be a positive integer/], + [{ modelOverrides: { 'deepseek-flash': { inputModalities: ['text'], imageMaxBytes: 1 } } }, /modelOverrides\.deepseek-flash is invalid: .*cannot declare image request limits/], +])('rejects %j', (options, message) => { + expect(() => Reflect.apply(resolveAccountOptions, undefined, [options])).toThrow(message) +}) + +it('keeps the fields an override declares and drops inherited empty lists', () => { + expect(resolveAccountOptions({ modelOverrides: { + 'deepseek-flash': { contextWindow: 524_288, imagePixelBudget: 'low', inputModalities: [] }, + } }).modelOverrides).toEqual({ 'deepseek-flash': { contextWindow: 524_288, imagePixelBudget: 'low' } }) +}) + +it('reads the current value behind every Config reference', () => { + const options = plainAccountOptions(Config({ modelsTimeoutMs: 7, modelOverrides: { 'deepseek-flash': { contextWindow: 9 } } })) + expect(options).toMatchObject({ modelsTimeoutMs: 7, modelsMaxAgeMs: 600_000, modelOverrides: { 'deepseek-flash': { contextWindow: 9 } } }) + expect(options).not.toHaveProperty('models') + expect(options).not.toHaveProperty('apiKeyEnv') +}) + +it('passes a retired models setting through to the resolver, which rejects it', () => { + const options = plainAccountOptions(Config({ models: [] })) + expect(options).toHaveProperty('models', []) + expect(() => resolveAccountOptions(options)).toThrow(/models is no longer configurable/) +}) diff --git a/packages/llm/llm-deepseek-account/tests/provider.spec.ts b/packages/llm/llm-deepseek-account/tests/provider.spec.ts index 1ac9db623d..f1f16becb5 100644 --- a/packages/llm/llm-deepseek-account/tests/provider.spec.ts +++ b/packages/llm/llm-deepseek-account/tests/provider.spec.ts @@ -19,12 +19,12 @@ it('keeps discovery and disposal independent across the two credential routes', await ctx.plugin(LlmRuntime) let token: string | undefined = 'fixture-account-token' ctx.provide('deepseekAccount', { resolveToken: async (_baseURL: string): Promise => token } as DeepSeekAccount) - const account = ctx.plugin(Account, { models: [{ id: 'account-model', name: 'Account model' }] }) + const account = ctx.plugin(Account, {}) await account expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-account']) const official = ctx.plugin(ApiKey, { models: [{ id: 'api-key-model', name: 'API-key model' }] }) await official - expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['account-model']) + expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['deepseek-flash', 'deepseek-v4-pro']) expect((await ctx.llm.listModels('deepseek-official')).map(row => row.id)).toEqual(['api-key-model']) expect(ctx.llm.listConfigurableProviders().map(row => [row.provider, row.settingsNs])).toEqual([ ['deepseek-account', 'llm-deepseek-account'], ['deepseek-official', 'llm-deepseek-api-key'], @@ -34,7 +34,7 @@ it('keeps discovery and disposal independent across the two credential routes', expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) expect(await ctx.llm.listModels('deepseek-official')).toHaveLength(1) token = 'new-account-token' - expect(await ctx.llm.listModels('deepseek-account')).toHaveLength(1) + expect(await ctx.llm.listModels('deepseek-account')).toHaveLength(2) await account.dispose() expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-official']) expect(ctx.llm.listConfigurableProviders().map(row => row.provider)).toEqual(['deepseek-official']) diff --git a/packages/llm/llm-deepseek-account/tsconfig.json b/packages/llm/llm-deepseek-account/tsconfig.json index 1f3eb5770a..db02fde98c 100644 --- a/packages/llm/llm-deepseek-account/tsconfig.json +++ b/packages/llm/llm-deepseek-account/tsconfig.json @@ -25,6 +25,18 @@ }, { "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/values" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/llm/llm-deepseek/src/config.ts b/packages/llm/llm-deepseek/src/config.ts index 0bb302fa29..a4c2cfaac0 100644 --- a/packages/llm/llm-deepseek/src/config.ts +++ b/packages/llm/llm-deepseek/src/config.ts @@ -65,17 +65,26 @@ export function plainOptions(config: Config): Options { return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, isVolatile(value) ? value.get() : value])) } -const catalogModel: z = z.object({ - id: z.string().required(), +/** + * Schemas of the catalog-entry fields other than `id` and `inputModalities`, + * whose requiredness and defaults differ between a `models` entry and a + * provider's per-model overrides. + */ +export const catalogModelFields = { name: z.string(), description: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), - inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), imagePixelBudget: z.union([z.number().step(1).min(1), 'low']), imageMaxBytes: z.number().step(1).min(1), systemPromptUpdate: z.const('in-history'), toolUpdate: z.union(['in-history', 'addition-only'] as const), +} + +const catalogModel: z = z.object({ + id: z.string().required(), + ...catalogModelFields, + inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), }) /** Shared schema fields for Messages protocol options. */ diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 623dbea335..3684ef56bd 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,5 +1,5 @@ /** DeepSeek Messages transport, request configuration, and model capabilities. */ -export { deepSeekConfigFields, Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' +export { catalogModelFields, deepSeekConfigFields, Config, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' export type { Options, ResolvedDeepSeekOptions } from './config.ts' export { DEFAULT_CONTEXT_WINDOW, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88b01aad87..4b3613e888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9402,6 +9402,19 @@ importers: version: link:../../core/tools packages/llm/llm-deepseek-account: + dependencies: + '@deepseek-ai/cosmokit': + specifier: link:../../../vendor/cosmokit + version: link:../../../vendor/cosmokit + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + semver: + specifier: ^7.8.5 + version: 7.8.5 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -9424,6 +9437,12 @@ importers: '@deepseek-ai/dsh-llm-deepseek-api-key': specifier: workspace:* version: link:../llm-deepseek-api-key + '@deepseek-ai/dsh-timeout': + specifier: workspace:* + version: link:../../util/timeout + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 packages/llm/llm-deepseek-api-key: dependencies: @@ -19010,13 +19029,9 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@7.2.0: - resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -26435,15 +26450,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -28179,7 +28185,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.0 + glob: 7.2.3 roarr@2.15.4: dependencies: From ad0a30075fafa87b7ce0bf9521f80d80a39b0402 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:04:10 +0800 Subject: [PATCH 004/121] feat(llm): decode the DeepSeek account model listing entry by entry --- .../llm/llm-deepseek-account/src/listing.ts | 169 ++++++++++++++++++ .../tests/listing.spec.ts | 149 +++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/llm/llm-deepseek-account/src/listing.ts create mode 100644 packages/llm/llm-deepseek-account/tests/listing.spec.ts diff --git a/packages/llm/llm-deepseek-account/src/listing.ts b/packages/llm/llm-deepseek-account/src/listing.ts new file mode 100644 index 0000000000..7f7c65283d --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/listing.ts @@ -0,0 +1,169 @@ +/** DeepSeek `GET /models` listing: one authenticated request and per-entry decoding. */ +import { APP_IDENTITY, attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm' +import type { ModelModality } from '@deepseek-ai/dsh-llm' + +const LISTED_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[] + +/** The requirement one listing entry declares under this client's product token. */ +export type ClientRequirement = + | { readonly kind: 'none' } + | { readonly kind: 'min-version'; readonly version: string } + | { readonly kind: 'invalid' } + +/** Capabilities a usable listing entry declares, in catalog vocabulary. */ +export interface ListedFacts { + /** Accepted request modalities this build sends. */ + readonly inputModalities: readonly ModelModality[] + /** Combined request and response context, when declared. */ + readonly contextWindow?: number + /** Output capability, when declared. */ + readonly maxOutputTokens?: number + /** Messages system prompt mode, when declared; any value other than `in-history` reads as `leading-only`. */ + readonly systemPromptUpdate?: 'in-history' | 'leading-only' +} + +/** Declared capabilities, or why the Messages transport cannot drive the model. */ +export type ListedCapabilities = + | { readonly usable: true; readonly facts: ListedFacts } + | { readonly usable: false; readonly reason: string } + +/** One decoded listing entry; unusable capabilities never hide its id, name, or requirement. */ +export interface ListingEntry { + /** Wire model id. */ + readonly id: string + /** Display name, when the entry declares a non-empty one. */ + readonly name?: string + /** Requirement declared under `client_requirements[APP_IDENTITY.product]`. */ + readonly requirement: ClientRequirement + /** Declared capabilities. */ + readonly capabilities: ListedCapabilities +} + +/** A decoded listing with at least one entry. */ +export interface ModelListing { + /** Entries in listing order. */ + readonly entries: readonly ListingEntry[] + /** Rows without a usable id, and repeated ids. */ + readonly skipped: number +} + +type Fields = Readonly> + +function fieldsOf(value: unknown): Fields | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Fields : undefined +} + +/** Listed modalities this build sends, in listing order without repeats; `undefined` when the value is not an array. */ +function knownModalities(value: unknown): ModelModality[] | undefined { + if (!Array.isArray(value)) return undefined + const listed: readonly unknown[] = value + return LISTED_MODALITIES.filter(modality => listed.includes(modality)) + .toSorted((left, right) => listed.indexOf(left) - listed.indexOf(right)) +} + +/** A declared capacity: `undefined` when absent, `false` when not a positive safe integer. */ +function capacity(value: unknown): number | undefined | false { + if (value === undefined) return undefined + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : false +} + +function requirementOf(entry: Fields): ClientRequirement { + if (entry.client_requirements === undefined) return { kind: 'none' } + const requirements = fieldsOf(entry.client_requirements) + if (requirements === undefined) return { kind: 'invalid' } + const declared = requirements[APP_IDENTITY.product] + if (declared === undefined) return { kind: 'none' } + const own = fieldsOf(declared) + if (own === undefined) return { kind: 'invalid' } + if (own.min_version === undefined) return { kind: 'none' } + return typeof own.min_version === 'string' && own.min_version.length > 0 + ? { kind: 'min-version', version: own.min_version } + : { kind: 'invalid' } +} + +function capabilitiesOf(entry: Fields): ListedCapabilities { + const messages = fieldsOf(fieldsOf(entry.api_capabilities)?.anthropic_messages) + if (messages === undefined) return { usable: false, reason: 'declares no api_capabilities.anthropic_messages' } + const outputs = entry.output_modalities === undefined ? ['text'] : knownModalities(entry.output_modalities) + if (outputs?.includes('text') !== true) return { usable: false, reason: 'declares no text output' } + const inputs = entry.input_modalities === undefined ? ['text' as const] : knownModalities(entry.input_modalities) + if (inputs === undefined || inputs.length === 0) return { usable: false, reason: 'declares no input modality this build sends' } + const contextWindow = capacity(entry.context_window) + const maxOutputTokens = capacity(entry.max_output_tokens) + if (contextWindow === false || maxOutputTokens === false) { + return { usable: false, reason: 'declares an invalid context_window or max_output_tokens' } + } + const update = messages.system_prompt_update + return { + usable: true, + facts: { + inputModalities: inputs, + ...contextWindow === undefined ? {} : { contextWindow }, + ...maxOutputTokens === undefined ? {} : { maxOutputTokens }, + ...update === undefined ? {} : { systemPromptUpdate: update === 'in-history' ? 'in-history' : 'leading-only' }, + }, + } +} + +/** + * Decode one `/models` body entry by entry. + * @param body - parsed JSON response. + * @returns the entries with a usable id, in listing order. + * @throws LlmError `INVALID_RESPONSE` when the body has no `data` array or no usable entry. + */ +export function parseModelListing(body: unknown): ModelListing { + const data = fieldsOf(body)?.data + if (!Array.isArray(data)) throw new LlmError('DeepSeek model listing has no "data" array', 'INVALID_RESPONSE') + const rows: readonly unknown[] = data + const entries: ListingEntry[] = [] + const seen = new Set() + for (const row of rows) { + const entry = fieldsOf(row) + const id = entry?.id + if (entry === undefined || typeof id !== 'string' || id.length === 0 || seen.has(id)) continue + seen.add(id) + entries.push({ + id, + ...typeof entry.name === 'string' && entry.name.length > 0 ? { name: entry.name } : {}, + requirement: requirementOf(entry), + capabilities: capabilitiesOf(entry), + }) + } + if (entries.length === 0) throw new LlmError('DeepSeek model listing contains no model entry', 'INVALID_RESPONSE') + return { entries, skipped: rows.length - entries.length } +} + +/** + * Request and decode one model listing. Redirects fail, so credentials stay on the requested origin. + * @param url - listing endpoint. + * @param headers - authentication headers for this endpoint. + * @param signal - cancellation and deadline of the whole exchange. + * @returns the decoded listing. + * @throws LlmError `ABORTED` or `TRANSPORT` before a complete body, `HTTP_` for a non-2xx answer, + * and `INVALID_RESPONSE` for a body that is not a JSON listing. + */ +export async function requestModelListing( + url: string, headers: Readonly>, signal: AbortSignal, +): Promise { + let response: Response + let text: string + try { + response = await fetch(url, { + method: 'GET', redirect: 'error', signal, + headers: { ...attributionHeaders(), accept: 'application/json', ...headers }, + }) + text = await response.text() + } catch (error) { + throw signal.aborted + ? new LlmError(`DeepSeek model listing request to ${url} was aborted`, 'ABORTED', { cause: error }) + : new LlmError(`could not reach ${url}`, 'TRANSPORT', { cause: error }) + } + if (!response.ok) throw new LlmError(`${url} answered HTTP ${response.status}`, `HTTP_${response.status}`, { status: response.status }) + let body: unknown + try { + body = JSON.parse(text) + } catch (error) { + throw new LlmError(`${url} did not answer with JSON`, 'INVALID_RESPONSE', { cause: error }) + } + return parseModelListing(body) +} diff --git a/packages/llm/llm-deepseek-account/tests/listing.spec.ts b/packages/llm/llm-deepseek-account/tests/listing.spec.ts new file mode 100644 index 0000000000..02488f2f64 --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/listing.spec.ts @@ -0,0 +1,149 @@ +/** `/models` decoding and the listing request lifecycle. */ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' +import { parseModelListing, requestModelListing } from '../src/listing.ts' + +/** The account listing the service returned on 2026-09-24. */ +const LISTING = { + object: 'list', + data: [ + { + id: 'deepseek-flash', object: 'model', owned_by: 'deepseek', name: 'DeepSeek-V4.1-Flash', + context_window: 1_048_576, max_output_tokens: 393_216, input_modalities: ['text', 'image'], output_modalities: ['text'], + effort: { supported_levels: ['low', 'high', 'max'], default_level: 'high' }, + api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history' } }, + }, + { + id: 'deepseek-v4-pro', object: 'model', owned_by: 'deepseek', name: 'DeepSeek-V4-Pro', + context_window: 1_048_576, max_output_tokens: 393_216, input_modalities: ['text'], output_modalities: ['text'], + effort: { supported_levels: ['low', 'high', 'max'], default_level: 'high' }, + api_capabilities: { anthropic_messages: { system_prompt_update: 'leading-only' } }, + }, + ], +} +const MESSAGES = { api_capabilities: { anthropic_messages: {} } } +const only = (row: object) => parseModelListing({ data: [{ id: 'm', ...row }] }).entries[0] + +describe('parseModelListing', () => { + it('decodes the service listing', () => { + expect(parseModelListing(LISTING)).toEqual({ + skipped: 0, + entries: [ + { + id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', requirement: { kind: 'none' }, + capabilities: { usable: true, facts: { + inputModalities: ['text', 'image'], contextWindow: 1_048_576, maxOutputTokens: 393_216, systemPromptUpdate: 'in-history', + } }, + }, + { + id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', requirement: { kind: 'none' }, + capabilities: { usable: true, facts: { + inputModalities: ['text'], contextWindow: 1_048_576, maxOutputTokens: 393_216, systemPromptUpdate: 'leading-only', + } }, + }, + ], + }) + }) + + it('skips rows without a usable id and repeated ids', () => { + const listing = parseModelListing({ data: [null, 1, { id: '' }, { id: 'a', name: '' }, { id: 'a' }] }) + expect(listing.skipped).toBe(4) + expect(listing.entries).toEqual([{ id: 'a', requirement: { kind: 'none' }, capabilities: expect.objectContaining({ usable: false }) }]) + }) + + it.each([{}, [], null, { data: {} }, { data: [] }, { data: [{ id: '' }] }])('rejects %j as a listing', (body) => { + expect(() => parseModelListing(body)).toThrow(/DeepSeek model listing/) + }) + + it.each([ + [undefined, { kind: 'none' }], + [{ other: { min_version: '9.0.0' } }, { kind: 'none' }], + [{ [APP_IDENTITY.product]: {} }, { kind: 'none' }], + [{ [APP_IDENTITY.product]: { min_version: '0.2.0-alpha.1' } }, { kind: 'min-version', version: '0.2.0-alpha.1' }], + ['0.2.0', { kind: 'invalid' }], + [{ [APP_IDENTITY.product]: '0.2.0' }, { kind: 'invalid' }], + [{ [APP_IDENTITY.product]: { min_version: 2 } }, { kind: 'invalid' }], + [{ [APP_IDENTITY.product]: { min_version: '' } }, { kind: 'invalid' }], + ])('reads client requirements %j', (requirements, expected) => { + expect(only({ ...MESSAGES, ...requirements === undefined ? {} : { client_requirements: requirements } })?.requirement).toEqual(expected) + }) + + it.each([ + [{}, 'declares no api_capabilities.anthropic_messages'], + [{ api_capabilities: { anthropic_messages: 'yes' } }, 'declares no api_capabilities.anthropic_messages'], + [{ ...MESSAGES, output_modalities: ['image'] }, 'declares no text output'], + [{ ...MESSAGES, output_modalities: 'text' }, 'declares no text output'], + [{ ...MESSAGES, input_modalities: ['audio'] }, 'declares no input modality this build sends'], + [{ ...MESSAGES, input_modalities: 'text' }, 'declares no input modality this build sends'], + [{ ...MESSAGES, context_window: 0 }, 'declares an invalid context_window or max_output_tokens'], + [{ ...MESSAGES, max_output_tokens: 1.5 }, 'declares an invalid context_window or max_output_tokens'], + ])('marks %j unusable', (row, reason) => { + expect(only(row)?.capabilities).toEqual({ usable: false, reason }) + }) + + it('narrows modalities to this build\'s vocabulary and reads an unknown system prompt mode as leading-only', () => { + expect(only({ + input_modalities: ['text', 'audio', 'text'], + api_capabilities: { anthropic_messages: { system_prompt_update: 'future-mode' } }, + })?.capabilities).toEqual({ usable: true, facts: { inputModalities: ['text'], systemPromptUpdate: 'leading-only' } }) + expect(only(MESSAGES)?.capabilities).toEqual({ usable: true, facts: { inputModalities: ['text'] } }) + }) +}) + +describe('requestModelListing', () => { + const servers: Server[] = [] + afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + }) + + async function listen(reply: (request: IncomingMessage, response: ServerResponse) => void): Promise { + const server = createServer(reply) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return `http://127.0.0.1:${address.port}/models` + } + + it('sends attribution, accept, and the given authentication headers', async () => { + let headers: IncomingMessage['headers'] = {} + const url = await listen((request, response) => { + headers = request.headers + response.end(JSON.stringify(LISTING)) + }) + const listing = await requestModelListing(url, { 'x-dsh-auth-token': 'fixture-token' }, AbortSignal.timeout(5_000)) + expect(listing.entries.map(entry => entry.id)).toEqual(['deepseek-flash', 'deepseek-v4-pro']) + expect(headers).toMatchObject({ + 'accept': 'application/json', + 'x-dsh-auth-token': 'fixture-token', + 'user-agent': expect.stringContaining(`${APP_IDENTITY.product}/${APP_IDENTITY.version}`), + }) + }) + + it.each([ + [401, 'Authentication Fails (governor)', 'HTTP_401'], + [200, '', 'INVALID_RESPONSE'], + [200, JSON.stringify({ data: [] }), 'INVALID_RESPONSE'], + ])('classifies HTTP %d with body %s', async (status, body, code) => { + const url = await listen((_request, response) => { response.writeHead(status).end(body) }) + await expect(requestModelListing(url, {}, AbortSignal.timeout(5_000))).rejects.toMatchObject({ code }) + }) + + it('refuses redirects', async () => { + const url = await listen((_request, response) => { response.writeHead(302, { location: 'http://127.0.0.1:1/models' }).end() }) + await expect(requestModelListing(url, {}, AbortSignal.timeout(5_000))).rejects.toMatchObject({ code: 'TRANSPORT' }) + }) + + it('reports an unreachable endpoint as a transport failure', async () => { + const url = await listen(() => { /* closed before any request */ }) + await new Promise(resolve => servers.pop()?.close(resolve)) + await expect(requestModelListing(url, {}, AbortSignal.timeout(5_000))).rejects.toMatchObject({ code: 'TRANSPORT' }) + }) + + it('reports the deadline as an aborted request', async () => { + const url = await listen(() => { /* never answers */ }) + await expect(requestModelListing(url, {}, AbortSignal.timeout(20))).rejects.toMatchObject({ code: 'ABORTED' }) + }) +}) From d8c98687639911e1ab804d7b7b20f9f4208e2316 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:06:13 +0800 Subject: [PATCH 005/121] feat(llm): gate DeepSeek account models by declared client version and bundled support --- .../src/resolve-catalog.ts | 137 ++++++++++++++++++ .../tests/resolve-catalog.spec.ts | 131 +++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/llm/llm-deepseek-account/src/resolve-catalog.ts create mode 100644 packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts diff --git a/packages/llm/llm-deepseek-account/src/resolve-catalog.ts b/packages/llm/llm-deepseek-account/src/resolve-catalog.ts new file mode 100644 index 0000000000..b5bf033bbe --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/resolve-catalog.ts @@ -0,0 +1,137 @@ +/** The account catalog: version gating, this build's model knowledge, and deployment overrides. */ +import semver from 'semver' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { DeepSeekCatalogModel } from '@deepseek-ai/dsh-llm-deepseek' +import type { ModelOverride } from './config.ts' +import type { ListedFacts, ListingEntry } from './listing.ts' + +/** Why this build refuses one listed model before network I/O. */ +export type CatalogRefusal = + | { readonly kind: 'upgrade-required'; readonly requiredVersion: string } + | { readonly kind: 'unsupported' } + +/** Inputs of one catalog resolution. */ +export interface AccountCatalogInput { + /** Entries of the last successful listing, or `undefined` before one succeeds. */ + readonly listing: readonly ListingEntry[] | undefined + /** Validated models this build supports, carrying its client-side knowledge. */ + readonly supported: readonly DeepSeekCatalogModel[] + /** Overrides by model id, holding only the fields each one declares. */ + readonly overrides: Readonly> + /** Route output cap that a listed output capability bounds. */ + readonly routeMaxTokens: number + /** This build's semantic version. */ + readonly clientVersion: string +} + +/** One resolved account catalog. */ +export interface AccountCatalog { + /** Selectable models in listing order, or this build's models when no listing succeeded. */ + readonly offered: readonly DeepSeekCatalogModel[] + /** Entries request resolution consults: offered models, then supported models the listing does not offer. */ + readonly resolution: readonly DeepSeekCatalogModel[] + /** Listed models refused before network I/O, by id. */ + readonly refusals: ReadonlyMap + /** Listing entries hidden because their declarations are unreadable or unusable. */ + readonly problems: readonly string[] + /** Configuration diagnostics for `modelOverrides`. */ + readonly overrideErrors: readonly string[] +} + +/** Replace fields of an entry; a text-only result drops the image limits it inherited. */ +function layered(model: DeepSeekCatalogModel, layer: ModelOverride): DeepSeekCatalogModel { + const merged: DeepSeekCatalogModel = { ...model, ...layer } + if (merged.inputModalities?.includes('image') !== true) { + if (layer.imagePixelBudget === undefined) delete merged.imagePixelBudget + if (layer.imageMaxBytes === undefined) delete merged.imageMaxBytes + } + return merged +} + +/** Layer one usable listing entry over this build's entry for the same id. */ +function listed(base: DeepSeekCatalogModel, entry: ListingEntry, facts: ListedFacts, routeMaxTokens: number): DeepSeekCatalogModel { + const { systemPromptUpdate: _replaced, ...leadingOnly } = base + return layered(facts.systemPromptUpdate === 'leading-only' ? leadingOnly : base, { + ...entry.name === undefined ? {} : { name: entry.name }, + ...facts.contextWindow === undefined ? {} : { contextWindow: facts.contextWindow }, + ...facts.maxOutputTokens === undefined ? {} : { maxTokens: Math.min(routeMaxTokens, facts.maxOutputTokens) }, + ...facts.systemPromptUpdate === 'in-history' ? { systemPromptUpdate: 'in-history' as const } : {}, + inputModalities: [...facts.inputModalities], + }) +} + +function gateOf(entry: ListingEntry, known: boolean, clientVersion: string, problems: string[]): CatalogRefusal | undefined { + const requirement = entry.requirement + switch (requirement.kind) { + case 'none': + return known ? undefined : { kind: 'unsupported' } + case 'min-version': + if (semver.valid(requirement.version) === null) break + return semver.gte(clientVersion, requirement.version) + ? undefined + : { kind: 'upgrade-required', requiredVersion: requirement.version } + case 'invalid': + break + /* v8 ignore next 2 -- listing decoding produces only the closed requirement union */ + default: + return assertNever(requirement, 'model listing client requirement') + } + problems.push(`model "${entry.id}" declares an unreadable client requirement`) + return { kind: 'unsupported' } +} + +/** + * Resolve the account catalog. A declared minimum client version decides a + * listed model's availability; without one, only models this build supports + * are offered and the other listed models stay hidden. + * @param input - listing, supported models, overrides, route cap, and this build's version. + * @returns offered models, request-resolution entries, refusals, and diagnostics. + */ +export function resolveAccountCatalog(input: AccountCatalogInput): AccountCatalog { + const supported = new Map(input.supported.map(model => [model.id, model])) + const problems: string[] = [] + const overrideErrors: string[] = [] + const withOverride = (model: DeepSeekCatalogModel): DeepSeekCatalogModel => { + const override = input.overrides[model.id] + if (override === undefined) return model + const candidate = layered(model, override) + try { + resolveAdapterOptions({ models: [candidate] }) + } catch (error) { + overrideErrors.push(`modelOverrides.${model.id} does not apply (${String(error)})`) + return model + } + return candidate + } + if (input.listing === undefined) { + const resolution = resolveAdapterOptions({ models: input.supported.map(withOverride) }).models + return { offered: resolution, resolution, refusals: new Map(), problems, overrideErrors } + } + const offered: DeepSeekCatalogModel[] = [] + const refusals = new Map() + for (const entry of input.listing) { + const refusal = gateOf(entry, supported.has(entry.id), input.clientVersion, problems) + if (refusal !== undefined) { + refusals.set(entry.id, refusal) + continue + } + if (!entry.capabilities.usable) { + problems.push(`model "${entry.id}" ${entry.capabilities.reason}`) + refusals.set(entry.id, { kind: 'unsupported' }) + continue + } + offered.push(withOverride(listed(supported.get(entry.id) ?? { id: entry.id }, entry, entry.capabilities.facts, input.routeMaxTokens))) + } + const offeredIds = new Set(offered.map(model => model.id)) + const listedIds = new Set(input.listing.map(entry => entry.id)) + for (const id of Object.keys(input.overrides)) { + if (!listedIds.has(id) && !supported.has(id)) { + overrideErrors.push(`modelOverrides names "${id}", which neither the account listing nor this build describes`) + } + } + const resolution = resolveAdapterOptions({ + models: [...offered, ...input.supported.filter(model => !offeredIds.has(model.id)).map(withOverride)], + }).models + return { offered: resolution.slice(0, offered.length), resolution, refusals, problems, overrideErrors } +} diff --git a/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts b/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts new file mode 100644 index 0000000000..e9cc30b84f --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts @@ -0,0 +1,131 @@ +/** Catalog resolution: version gating, this build's model knowledge, and model overrides. */ +import { describe, expect, it } from 'vitest' +import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' +import type { ModelOverride } from '../src/config.ts' +import { parseModelListing } from '../src/listing.ts' +import { resolveAccountCatalog } from '../src/resolve-catalog.ts' + +const FLASH = { + id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', context_window: 1_048_576, max_output_tokens: 393_216, + input_modalities: ['text', 'image'], output_modalities: ['text'], + api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history' } }, +} +const PRO = { + id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', context_window: 1_048_576, max_output_tokens: 393_216, + input_modalities: ['text'], output_modalities: ['text'], + api_capabilities: { anthropic_messages: { system_prompt_update: 'leading-only' } }, +} +const requires = (version: unknown) => ({ client_requirements: { 'deepseek-harness': { min_version: version } } }) +const resolve = (rows: object[] | undefined, overrides: Record = {}) => resolveAccountCatalog({ + listing: rows === undefined ? undefined : parseModelListing({ data: rows }).entries, + supported: resolveAdapterOptions({}).models, + overrides, + routeMaxTokens: 256_000, + clientVersion: '0.2.0-rc.1', +}) + +describe('without a listing', () => { + it('offers the bundled catalog', () => { + const catalog = resolve(undefined) + expect(catalog.offered.map(model => [model.id, model.name, model.contextWindow])).toEqual([ + ['deepseek-flash', 'DeepSeek-V4.1-Flash', 1_000_000], ['deepseek-v4-pro', 'DeepSeek-V4-Pro', 1_000_000], + ]) + expect(catalog.resolution).toEqual(catalog.offered) + expect(catalog.refusals.size).toBe(0) + }) + + it('applies overrides and does not judge override ids it cannot see', () => { + const catalog = resolve(undefined, { 'deepseek-flash': { contextWindow: 524_288 }, 'remote-only': { contextWindow: 1 } }) + expect(catalog.offered[0]?.contextWindow).toBe(524_288) + expect(catalog.overrideErrors).toEqual([]) + }) +}) + +describe('with a listing', () => { + it('layers listed capabilities over this build\'s knowledge in listing order', () => { + const catalog = resolve([PRO, FLASH]) + expect(catalog.offered.map(model => model.id)).toEqual(['deepseek-v4-pro', 'deepseek-flash']) + expect(catalog.offered[1]).toEqual({ + id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', contextWindow: 1_048_576, maxTokens: 256_000, + inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', toolUpdate: 'addition-only', + imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, + }) + expect(catalog.offered[0]).not.toHaveProperty('systemPromptUpdate') + expect(catalog.offered[0]?.description).toContain('agentic coding') + }) + + it('bounds the request default by a smaller output capability', () => { + expect(resolve([{ ...PRO, max_output_tokens: 100_000 }]).offered[0]?.maxTokens).toBe(100_000) + }) + + it('removes an inherited in-history declaration the listing does not make', () => { + const [flash] = resolve([{ ...FLASH, api_capabilities: { anthropic_messages: { system_prompt_update: 'leading-only' } } }]).offered + expect(flash).not.toHaveProperty('systemPromptUpdate') + }) + + it('drops inherited image limits from a model the listing makes text-only', () => { + const [flash] = resolve([{ ...FLASH, input_modalities: ['text'] }]).offered + expect(flash).toMatchObject({ inputModalities: ['text'] }) + expect(flash).not.toHaveProperty('imageMaxBytes') + }) + + it.each([ + ['known, undeclared', { ...FLASH }, 'offered'], + ['known, satisfied prerelease floor', { ...FLASH, ...requires('0.2.0-alpha.1') }, 'offered'], + ['known, unmet floor', { ...FLASH, ...requires('0.2.0') }, { kind: 'upgrade-required', requiredVersion: '0.2.0' }], + ['unknown, undeclared', { ...FLASH, id: 'deepseek-v5' }, { kind: 'unsupported' }], + ['unknown, satisfied floor', { ...FLASH, id: 'deepseek-v5', ...requires('0.1.0') }, 'offered'], + ['unknown, unmet floor', { ...FLASH, id: 'deepseek-v5', ...requires('0.3.0-alpha.1') }, { kind: 'upgrade-required', requiredVersion: '0.3.0-alpha.1' }], + ['unreadable floor', { ...FLASH, ...requires('soon') }, { kind: 'unsupported' }], + ['malformed requirement', { ...FLASH, client_requirements: 'x' }, { kind: 'unsupported' }], + ])('gates a %s model', (_case, row, expected) => { + const catalog = resolve([row]) + if (expected === 'offered') { + expect(catalog.offered.map(model => model.id)).toEqual([row.id]) + expect(catalog.refusals.size).toBe(0) + } else { + expect(catalog.offered).toEqual([]) + expect(catalog.refusals.get(row.id)).toEqual(expected) + } + }) + + it('serves a vouched unknown model with listed metadata only', () => { + const [model] = resolve([{ ...FLASH, id: 'deepseek-v5', ...requires('0.1.0') }]).offered + expect(model).not.toHaveProperty('toolUpdate') + expect(model).toMatchObject({ id: 'deepseek-v5', contextWindow: 1_048_576, imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES }) + }) + + it('reports unreadable requirements and unusable declarations, but not unknown models', () => { + const catalog = resolve([{ ...FLASH, ...requires('soon') }, { id: 'deepseek-v4-pro' }, { ...FLASH, id: 'deepseek-v5' }]) + expect(catalog.problems).toEqual([ + 'model "deepseek-flash" declares an unreadable client requirement', + 'model "deepseek-v4-pro" declares no api_capabilities.anthropic_messages', + ]) + expect(catalog.refusals.get('deepseek-v4-pro')).toEqual({ kind: 'unsupported' }) + }) + + it('keeps bundled knowledge, with overrides, for request resolution of models the listing does not offer', () => { + const catalog = resolve([PRO], { 'deepseek-flash': { contextWindow: 7 } }) + expect(catalog.offered.map(model => model.id)).toEqual(['deepseek-v4-pro']) + expect(catalog.resolution.map(model => [model.id, model.contextWindow])).toEqual([['deepseek-v4-pro', 1_048_576], ['deepseek-flash', 7]]) + expect(catalog.overrideErrors).toEqual([]) + }) + + it('applies an override to a vouched model and drops image limits an override narrows away', () => { + const catalog = resolve([FLASH, { ...PRO, id: 'deepseek-v5', ...requires('0.1.0') }], { + 'deepseek-flash': { inputModalities: ['text'] }, 'deepseek-v5': { contextWindow: 9 }, + }) + expect(catalog.offered[0]).not.toHaveProperty('imageMaxBytes') + expect(catalog.offered[1]?.contextWindow).toBe(9) + expect(catalog.overrideErrors).toEqual([]) + }) + + it.each([{ imagePixelBudget: 'low' as const }, { imageMaxBytes: 1 }])('reports an override %j that does not apply and a stale override id', (limits) => { + const catalog = resolve([PRO], { 'deepseek-v4-pro': limits, 'retired': { contextWindow: 1 } }) + expect(catalog.offered[0]).not.toHaveProperty('imageMaxBytes') + expect(catalog.overrideErrors).toEqual([ + expect.stringMatching(/^modelOverrides\.deepseek-v4-pro does not apply \(.*cannot declare image request limits/), + 'modelOverrides names "retired", which neither the account listing nor this build describes', + ]) + }) +}) From 2817b9e205f03840962b9dbd9e6697b961f7a95f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:07:59 +0800 Subject: [PATCH 006/121] test(llm): cover listing entries that omit optional account catalog fields --- .../tests/resolve-catalog.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts b/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts index e9cc30b84f..232c8dfa87 100644 --- a/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts +++ b/packages/llm/llm-deepseek-account/tests/resolve-catalog.spec.ts @@ -54,6 +54,17 @@ describe('with a listing', () => { expect(catalog.offered[0]?.description).toContain('agentic coding') }) + it('keeps this build\'s values for fields a listing omits', () => { + const [pro] = resolve([{ id: 'deepseek-v4-pro', api_capabilities: { anthropic_messages: {} } }]).offered + expect(pro).toMatchObject({ name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000, inputModalities: ['text'] }) + expect(pro).not.toHaveProperty('maxTokens') + }) + + it('serves a minimally declared vouched model with request defaults only', () => { + expect(resolve([{ id: 'deepseek-v5', api_capabilities: { anthropic_messages: {} }, ...requires('0.1.0') }]).offered) + .toEqual([{ id: 'deepseek-v5', inputModalities: ['text'] }]) + }) + it('bounds the request default by a smaller output capability', () => { expect(resolve([{ ...PRO, max_output_tokens: 100_000 }]).offered[0]?.maxTokens).toBe(100_000) }) From 31ce1ad5f18e2ddcd71e5f7cb90b162f5f6ebd0b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:08:00 +0800 Subject: [PATCH 007/121] feat(llm): cache the DeepSeek account listing per credential --- .../llm/llm-deepseek-account/src/catalog.ts | 127 +++++++++++++++ .../tests/catalog.spec.ts | 148 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 packages/llm/llm-deepseek-account/src/catalog.ts create mode 100644 packages/llm/llm-deepseek-account/tests/catalog.spec.ts diff --git a/packages/llm/llm-deepseek-account/src/catalog.ts b/packages/llm/llm-deepseek-account/src/catalog.ts new file mode 100644 index 0000000000..a5d8e3ddff --- /dev/null +++ b/packages/llm/llm-deepseek-account/src/catalog.ts @@ -0,0 +1,127 @@ +/** Credential-scoped `/models` cache with single-flight loads, background refresh, and last-known-good retention. */ +import { createHash } from 'node:crypto' +import type { CatalogSettings } from './config.ts' +import type { ModelListing } from './listing.ts' + +/** Host-owned inputs of one catalog instance. */ +export interface CatalogDependencies { + /** Current endpoint settings, re-read on every access. */ + readonly settings: () => CatalogSettings + /** The account token allowed for `url`, or `undefined` while signed out. */ + readonly token: (url: string) => Promise + /** Perform one listing request authenticated with `token`. */ + readonly load: (url: string, token: string, signal: AbortSignal) => Promise + /** Wall clock used for listing age. */ + readonly now: () => number + /** Report the first failure of a failing streak. */ + readonly warn: (message: string) => void +} + +/** What request resolution reads. */ +export interface CatalogView { + /** Whether an account token is available for the listing endpoint. */ + readonly signedIn: boolean + /** Last successful listing for this credential and endpoint; `undefined` until one succeeds. */ + readonly listing: ModelListing | undefined +} + +/** Wait for `task` unless `signal` aborts first; the task keeps running. */ +async function abandonable(task: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return task + const abandoned = Promise.withResolvers() + const abandon = (): void => { abandoned.reject(signal.reason) } + signal.addEventListener('abort', abandon, { once: true }) + try { + await Promise.race([task, abandoned.promise]) + } finally { + signal.removeEventListener('abort', abandon) + } +} + +/** One account route's listing cache; each endpoint and credential pair has its own scope. */ +export class AccountModelCatalog { + private scope: string | undefined + private listing: ModelListing | undefined + /** Settle time of the latest load in this scope; `undefined` before the first one settles. */ + private settledAt: number | undefined + private inflight: Promise | undefined + private request: AbortController | undefined + private generation = 0 + private failing = false + private closed = false + + /** @param dependencies - settings, credential, transport, clock, and diagnostic callbacks. */ + constructor(private readonly dependencies: CatalogDependencies) {} + + /** + * The last published view, without I/O. + * @returns the signed-in state and listing of the current scope. + */ + current(): CatalogView { + return { signedIn: this.scope !== undefined, listing: this.listing } + } + + /** + * Resolve the view for the current credential. The first access of a scope + * waits for its load, which the request deadline bounds; later accesses + * return at once and refresh a stale listing in the background. + * @param signal - abandons this wait only; the shared load continues. + * @returns the view after any first load of the scope settles. + */ + async view(signal?: AbortSignal): Promise { + const settings = this.dependencies.settings() + const token = await this.dependencies.token(settings.url) + signal?.throwIfAborted() + if (this.closed) return this.current() + const scope = token === undefined ? undefined : `${settings.url}\n${createHash('sha256').update(token).digest('hex')}` + if (scope !== this.scope) this.rescope(scope) + if (token !== undefined) { + if (this.settledAt === undefined) await abandonable(this.start(settings, token), signal) + else if (this.dependencies.now() - this.settledAt >= settings.maxAgeMs) void this.start(settings, token) + } + return this.current() + } + + /** Abort in-flight loads; later results and accesses change nothing. */ + dispose(): void { + this.closed = true + this.generation += 1 + this.request?.abort() + } + + private rescope(scope: string | undefined): void { + this.request?.abort() + this.scope = scope + this.listing = undefined + this.settledAt = undefined + this.inflight = undefined + this.failing = false + this.generation += 1 + } + + private start(settings: CatalogSettings, token: string): Promise { + if (this.inflight !== undefined) return this.inflight + this.generation += 1 + const generation = this.generation + const request = new AbortController() + this.request = request + const run = this.dependencies.load(settings.url, token, AbortSignal.any([request.signal, AbortSignal.timeout(settings.timeoutMs)])) + .then((listing) => { + if (generation !== this.generation) return + this.listing = listing + this.settledAt = this.dependencies.now() + this.failing = false + }, (error: unknown) => { + if (generation !== this.generation) return + if (!this.failing) { + const fallback = this.listing === undefined ? 'using the bundled catalog' : 'keeping the previous catalog' + this.dependencies.warn(`llm-deepseek-account: model listing from ${settings.url} failed (${String(error)}); ${fallback}`) + } + this.settledAt = this.dependencies.now() + this.failing = true + }) + .finally(() => { if (this.inflight === run) this.inflight = undefined }) + this.inflight = run + return run + } +} diff --git a/packages/llm/llm-deepseek-account/tests/catalog.spec.ts b/packages/llm/llm-deepseek-account/tests/catalog.spec.ts new file mode 100644 index 0000000000..826b7c7f64 --- /dev/null +++ b/packages/llm/llm-deepseek-account/tests/catalog.spec.ts @@ -0,0 +1,148 @@ +/** Credential-scoped listing cache: first-use waits, background refresh, retention, and disposal. */ +import { expect, it, vi } from 'vitest' +import { AccountModelCatalog } from '../src/catalog.ts' +import type { ModelListing } from '../src/listing.ts' + +const listing = (...ids: string[]): ModelListing => ({ + skipped: 0, + entries: ids.map(id => ({ id, requirement: { kind: 'none' }, capabilities: { usable: true, facts: { inputModalities: ['text'] } } })), +}) + +function harness(timeoutMs = 60_000, honorsAbort = true) { + let clock = 0 + let token: string | undefined = 'token-a' + const loads: Array<{ token: string; signal: AbortSignal; settle: PromiseWithResolvers }> = [] + const warn = vi.fn() + const catalog = new AccountModelCatalog({ + settings: () => ({ url: 'https://api.example/models', timeoutMs, maxAgeMs: 100 }), + token: () => Promise.resolve(token), + load: (_url, value, signal) => { + const settle = Promise.withResolvers() + if (honorsAbort) signal.addEventListener('abort', () => { settle.reject(signal.reason) }, { once: true }) + loads.push({ token: value, signal, settle }) + return settle.promise + }, + now: () => clock, + warn, + }) + const flush = () => new Promise(resolve => setImmediate(resolve)) + return { + catalog, loads, warn, flush, + advance: (ms: number) => { clock += ms }, + account: (value: string | undefined) => { token = value }, + } +} + +it('reports a signed-out view without loading', async () => { + const { catalog, loads, account } = harness() + account(undefined) + await expect(catalog.view()).resolves.toEqual({ signedIn: false, listing: undefined }) + expect(loads).toEqual([]) +}) + +it('shares one first load between concurrent callers', async () => { + const { catalog, loads, flush } = harness() + const first = catalog.view() + const second = catalog.view() + await flush() + expect(loads).toHaveLength(1) + loads[0]!.settle.resolve(listing('a')) + await expect(first).resolves.toEqual({ signedIn: true, listing: listing('a') }) + await expect(second).resolves.toEqual({ signedIn: true, listing: listing('a') }) + expect(catalog.current()).toEqual({ signedIn: true, listing: listing('a') }) +}) + +it('bounds the first wait by the request deadline and serves no listing after a failure', async () => { + const { catalog, warn } = harness(5) + await expect(catalog.view()).resolves.toEqual({ signedIn: true, listing: undefined }) + expect(warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('using the bundled catalog')) +}) + +it('abandons only the caller\'s wait when its signal aborts', async () => { + const { catalog, loads, flush } = harness() + const controller = new AbortController() + const waiting = catalog.view(controller.signal) + await flush() + controller.abort(new Error('caller left')) + await expect(waiting).rejects.toThrow('caller left') + expect(loads[0]!.signal.aborted).toBe(false) + loads[0]!.settle.resolve(listing('a')) + await flush() + await expect(catalog.view()).resolves.toMatchObject({ listing: listing('a') }) +}) + +it('rejects a caller whose signal is already aborted', async () => { + const { catalog } = harness() + await expect(catalog.view(AbortSignal.abort(new Error('gone')))).rejects.toThrow('gone') +}) + +it('refreshes a stale listing in the background and keeps it through a failing streak', async () => { + const { catalog, loads, warn, flush, advance } = harness() + const first = catalog.view() + await flush() + loads[0]!.settle.resolve(listing('a')) + await first + advance(50) + await catalog.view() + expect(loads).toHaveLength(1) + advance(50) + await expect(catalog.view()).resolves.toMatchObject({ listing: listing('a') }) + expect(loads).toHaveLength(2) + loads[1]!.settle.reject(new Error('down')) + await flush() + expect(warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('keeping the previous catalog')) + advance(100) + await catalog.view() + loads[2]!.settle.reject(new Error('still down')) + await flush() + expect(warn).toHaveBeenCalledTimes(1) + advance(100) + await catalog.view() + loads[3]!.settle.resolve(listing('a', 'b')) + await flush() + expect(catalog.current().listing).toEqual(listing('a', 'b')) + advance(100) + await catalog.view() + loads[4]!.settle.reject(new Error('down again')) + await flush() + expect(warn).toHaveBeenCalledTimes(2) +}) + +it('rescopes on another account and discards the previous account\'s result', async () => { + const { catalog, loads, flush, account } = harness() + const first = catalog.view() + await flush() + account('token-b') + const second = catalog.view() + await flush() + expect(loads[0]!.signal.aborted).toBe(true) + expect(loads[1]!.token).toBe('token-b') + loads[1]!.settle.resolve(listing('b')) + await expect(second).resolves.toMatchObject({ listing: listing('b') }) + await first + expect(catalog.current().listing).toEqual(listing('b')) + account(undefined) + await expect(catalog.view()).resolves.toEqual({ signedIn: false, listing: undefined }) +}) + +it('aborts in-flight work on dispose and loads nothing afterwards', async () => { + const { catalog, loads, warn, flush } = harness() + const waiting = catalog.view() + await flush() + catalog.dispose() + expect(loads[0]!.signal.aborted).toBe(true) + await waiting + await expect(catalog.view()).resolves.toEqual({ signedIn: true, listing: undefined }) + expect(loads).toHaveLength(1) + expect(warn).not.toHaveBeenCalled() +}) + +it('drops a successful result that settles after disposal', async () => { + const { catalog, loads, flush } = harness(60_000, false) + const waiting = catalog.view() + await flush() + catalog.dispose() + loads[0]!.settle.resolve(listing('late')) + await waiting + expect(catalog.current().listing).toBeUndefined() +}) From 0a13e89b0dd8d9094aa87fbaff867043250af72e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:10:36 +0800 Subject: [PATCH 008/121] feat(llm): serve the DeepSeek account catalog from the account listing --- .../llm/llm-deepseek-account/src/index.ts | 109 ++++++++++--- .../tests/provider.spec.ts | 146 ++++++++++++++++-- .../tests/account-routing.spec.ts | 12 +- 3 files changed, 231 insertions(+), 36 deletions(-) diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 864b0dd6da..73122b6bdb 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -1,50 +1,115 @@ -/** Account-token authentication and discovery for the DeepSeek account route. */ +/** Account-token authentication, the account model listing, and version gating for the DeepSeek account route. */ import type {} from '@deepseek-ai/cordis-plugin-loader' import type { Context } from '@deepseek-ai/cordis' -import { LlmError } from '@deepseek-ai/dsh-llm' +import { APP_IDENTITY, LlmError } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import { registerDeepSeekProvider, catalogModelInfo } from '@deepseek-ai/dsh-llm-deepseek' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { catalogModelInfo, registerDeepSeekProvider, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import type { DeepSeekRequestAuth, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' - +import { AccountModelCatalog } from './catalog.ts' +import type { CatalogView } from './catalog.ts' import { Config, plainAccountOptions, resolveAccountOptions } from './config.ts' +import { requestModelListing } from './listing.ts' +import type { ModelListing } from './listing.ts' +import { resolveAccountCatalog } from './resolve-catalog.ts' +import type { AccountCatalog, CatalogRefusal } from './resolve-catalog.ts' + export { Config } from './config.ts' export const name = 'llm-deepseek-account' export const inject = ['llm'] const PROVIDER = 'deepseek-account' +/** Header carrying the account token on Messages, Files, and model-listing requests. */ +const TOKEN_HEADER = 'x-dsh-auth-token' + +/** Connection facts of one account operation: protocol settings plus the catalog resolved with them. */ +interface AccountConnection extends ResolvedDeepSeekOptions { + readonly catalog: AccountCatalog +} + +function refuse(model: string, refusal: CatalogRefusal | undefined): void { + if (refusal === undefined) return + switch (refusal.kind) { + case 'upgrade-required': + throw new LlmError( + `DeepSeek model "${model}" requires DeepSeek Harness ${refusal.requiredVersion} or later; this build is ${APP_IDENTITY.version}`, + 'CLIENT_UPGRADE_REQUIRED', + ) + case 'unsupported': + throw new LlmError(`DeepSeek model "${model}" is not supported by DeepSeek Harness ${APP_IDENTITY.version}`, 'MODEL_UNSUPPORTED') + /* v8 ignore next 2 -- catalog resolution produces only the closed refusal union */ + default: + assertNever(refusal, 'account catalog refusal') + } +} export function apply(ctx: Context, config: Config): void { - const options = () => resolveAccountOptions(plainAccountOptions(config), launchEnvironmentOf(ctx)).protocol - options() + const environment = launchEnvironmentOf(ctx) + const settings = () => resolveAccountOptions(plainAccountOptions(config), environment) + settings() + const supported = resolveAdapterOptions({}).models + const account = () => ctx.get('deepseekAccount') const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise => { - const account = ctx.get('deepseekAccount') - const token = await account?.resolveToken(connection.baseURL) + const token = await account()?.resolveToken(connection.baseURL) if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') return { - headers: { 'x-dsh-auth-token': token }, + headers: { [TOKEN_HEADER]: token }, onRequestError: async (error) => { if (!(error instanceof LlmError) || error.failure.status !== 401) return error const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) - try { await account?.rejectToken(token) } + try { await account()?.rejectToken(token) } catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } return rejected }, } } - ctx.llm.registerConfigurableProviders([ - { provider: PROVIDER, displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] }, - ]) - registerDeepSeekProvider(ctx, PROVIDER, { - options, resolveAuth, providerName: 'DeepSeek Account', + const catalog = new AccountModelCatalog({ + settings: () => settings().catalog, + token: async url => account()?.resolveToken(url), + load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal), + now: () => Date.now(), + warn: (message) => { ctx.logger.warn(message) }, + }) + const connectionOf = (view: CatalogView): AccountConnection => { + const resolved = settings() + const result = resolveAccountCatalog({ + listing: view.listing?.entries, + supported, + overrides: resolved.modelOverrides, + routeMaxTokens: resolved.protocol.maxTokens, + clientVersion: APP_IDENTITY.version, + }) + return { ...resolved.protocol, models: result.resolution, catalog: result } + } + const entry = { provider: PROVIDER, displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] } + const directory = ctx.llm.registerConfigurableProviders([entry]) + let loggedListing: ModelListing | undefined + let reportedError: string | undefined + /** Log listing problems once per listing and publish override diagnostics on the provider directory. */ + const discovered = (view: CatalogView): AccountConnection => { + const connection = connectionOf(view) + if (view.listing !== loggedListing) { + loggedListing = view.listing + for (const problem of connection.catalog.problems) ctx.logger.warn(`llm-deepseek-account: ${problem}`) + } + const error = connection.catalog.overrideErrors.length === 0 ? undefined : connection.catalog.overrideErrors.join('; ') + if (error !== reportedError) { + reportedError = error + directory.replace([error === undefined ? entry : { ...entry, error }]) + } + return connection + } + registerDeepSeekProvider(ctx, PROVIDER, { + providerName: 'DeepSeek Account', + options: () => connectionOf(catalog.current()), + connect: async signal => connectionOf(await catalog.view(signal)), + resolveAuth, + admit: (connection, model) => { refuse(model, connection.catalog.refusals.get(model)) }, discoverModels: async (provider) => { - const connection = options() - try { await resolveAuth(connection) } - catch (error) { - if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return [] - throw error - } - return connection.models.map(model => catalogModelInfo(provider, model)) + const view = await catalog.view() + return view.signedIn ? discovered(view).catalog.offered.map(model => catalogModelInfo(provider, model)) : [] }, }) + ctx.effect(() => () => { catalog.dispose() }, 'llm-deepseek-account: model catalog') } diff --git a/packages/llm/llm-deepseek-account/tests/provider.spec.ts b/packages/llm/llm-deepseek-account/tests/provider.spec.ts index f1f16becb5..3061303288 100644 --- a/packages/llm/llm-deepseek-account/tests/provider.spec.ts +++ b/packages/llm/llm-deepseek-account/tests/provider.spec.ts @@ -1,30 +1,72 @@ -/** Account and API-key plugins own independent route registrations and catalogs. */ +/** The account route serves its /models listing, gates models, and keeps discovery independent of the API-key route. */ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' +import LlmRuntime, { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import * as ApiKey from '@deepseek-ai/dsh-llm-deepseek-api-key' -import { afterEach, expect, it, vi } from 'vitest' import * as Account from '../src/index.ts' +const MESSAGES = { output_modalities: ['text'], api_capabilities: { anthropic_messages: {} } } +const FLASH = { + id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', context_window: 1_048_576, max_output_tokens: 393_216, + input_modalities: ['text', 'image'], output_modalities: ['text'], + effort: { supported_levels: ['low', 'high', 'max'], default_level: 'high' }, + api_capabilities: { anthropic_messages: { system_prompt_update: 'in-history' } }, +} +const PRO = { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', context_window: 1_048_576, max_output_tokens: 393_216, input_modalities: ['text'], ...MESSAGES } +const requires = (version: string) => ({ client_requirements: { 'deepseek-harness': { min_version: version } } }) + const contexts: Context[] = [] +const servers: Server[] = [] afterEach(async () => { for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) vi.unstubAllEnvs() }) -it('keeps discovery and disposal independent across the two credential routes', async () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') +/** Loopback listing endpoint: `reply` answers GET /models; any other request fails the test through its status. */ +async function listing(reply: (request: IncomingMessage, response: ServerResponse) => void) { + const requests: IncomingMessage[] = [] + const server = createServer((request, response) => { + requests.push(request) + if (request.method === 'GET' && request.url === '/models') reply(request, response) + else response.writeHead(500).end('unexpected request') + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + const origin = `http://127.0.0.1:${address.port}` + return { origin, requests, baseURL: `${origin}/anthropic` } +} +const json = (body: unknown) => (_request: IncomingMessage, response: ServerResponse) => { + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(body)) +} + +async function host(origin: string, token: () => string | undefined = () => 'fixture-account-token') { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) + ctx.provide('deepseekAccount', { + resolveToken: (url: string) => Promise.resolve(new URL(url).origin === origin ? token() : undefined), + } as DeepSeekAccount) + return ctx +} + +it('keeps discovery and disposal independent across the two credential routes', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'fixture-api-key') + const server = await listing(json({ data: [FLASH, PRO] })) let token: string | undefined = 'fixture-account-token' - ctx.provide('deepseekAccount', { resolveToken: async (_baseURL: string): Promise => token } as DeepSeekAccount) - const account = ctx.plugin(Account, {}) + const ctx = await host(server.origin, () => token) + const account = ctx.plugin(Account, { baseURL: server.baseURL }) await account - expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-account']) const official = ctx.plugin(ApiKey, { models: [{ id: 'api-key-model', name: 'API-key model' }] }) await official - expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['deepseek-flash', 'deepseek-v4-pro']) + expect((await ctx.llm.listModels('deepseek-account')).map(row => [row.id, row.name])).toEqual([ + ['deepseek-flash', 'DeepSeek-V4.1-Flash'], ['deepseek-v4-pro', 'DeepSeek-V4-Pro'], + ]) expect((await ctx.llm.listModels('deepseek-official')).map(row => row.id)).toEqual(['api-key-model']) expect(ctx.llm.listConfigurableProviders().map(row => [row.provider, row.settingsNs])).toEqual([ ['deepseek-account', 'llm-deepseek-account'], ['deepseek-official', 'llm-deepseek-api-key'], @@ -32,13 +74,91 @@ it('keeps discovery and disposal independent across the two credential routes', expect(Account.Config({})).not.toHaveProperty('apiKeyEnv') token = undefined expect(await ctx.llm.listModels('deepseek-account')).toEqual([]) - expect(await ctx.llm.listModels('deepseek-official')).toHaveLength(1) - token = 'new-account-token' + token = 'second-account-token' expect(await ctx.llm.listModels('deepseek-account')).toHaveLength(2) + expect(server.requests.map(request => request.headers['x-dsh-auth-token'])).toEqual(['fixture-account-token', 'second-account-token']) await account.dispose() expect(ctx.llm.listProviders().map(row => row.id)).toEqual(['deepseek-official']) - expect(ctx.llm.listConfigurableProviders().map(row => row.provider)).toEqual(['deepseek-official']) await official.dispose() - expect(ctx.llm.listProviders()).toEqual([]) expect(ctx.llm.listConfigurableProviders()).toEqual([]) }) + +it('resolves listed capabilities with this build\'s model knowledge', async () => { + const server = await listing(json({ data: [FLASH, PRO] })) + const ctx = await host(server.origin) + await ctx.plugin(Account, { baseURL: server.baseURL }) + await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-flash')).resolves.toMatchObject({ + name: 'DeepSeek-V4.1-Flash', context: { contextWindow: 1_048_576 }, defaultMaxTokens: 256_000, + systemPromptUpdate: 'in-history', toolUpdate: 'addition-only', inputModalities: ['text', 'image'], + reasoning: { defaultEffort: ReasoningEffortId('high') }, + }) + await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-v4-pro')).resolves.not.toHaveProperty('systemPromptUpdate') + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.headers['user-agent']).toMatch(/^deepseek-harness\//) +}) + +it('falls back to the bundled catalog when the listing fails', async () => { + const server = await listing((_request, response) => { response.writeHead(503).end('down') }) + const ctx = await host(server.origin) + await ctx.plugin(Account, { baseURL: server.baseURL }) + expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['deepseek-flash', 'deepseek-v4-pro']) + await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-flash')).resolves.toMatchObject({ context: { contextWindow: 1_000_000 } }) +}) + +it('hides and refuses listed models this build cannot serve before any inference request', async () => { + const server = await listing(json({ data: [ + FLASH, + { ...PRO, id: 'deepseek-v5' }, + { ...PRO, id: 'deepseek-v5-open', ...requires('0.0.1') }, + { ...PRO, id: 'deepseek-v5-next', ...requires('99.0.0-alpha.1') }, + { id: 'deepseek-v4-pro' }, + ] })) + const ctx = await host(server.origin) + await ctx.plugin(Account, { baseURL: server.baseURL }) + expect((await ctx.llm.listModels('deepseek-account')).map(row => row.id)).toEqual(['deepseek-flash', 'deepseek-v5-open']) + await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'deepseek-v5' })).rejects.toMatchObject({ code: 'MODEL_UNSUPPORTED' }) + await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'deepseek-v4-pro' })).rejects.toMatchObject({ code: 'MODEL_UNSUPPORTED' }) + await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'deepseek-v5-next' })) + .rejects.toMatchObject({ code: 'CLIENT_UPGRADE_REQUIRED', message: expect.stringContaining('99.0.0-alpha.1') }) + await expect(ctx.llm.prepareCall({ provider: 'deepseek-account', model: 'unlisted-pass-through' })).resolves.toBeDefined() + expect(server.requests.map(request => request.url)).toEqual(['/models']) +}) + +it('applies model overrides and reports stale ones on the provider directory until they resolve', async () => { + let token = 'first-account' + const server = await listing((request, response) => { + const rows = request.headers['x-dsh-auth-token'] === 'first-account' ? [FLASH] : [FLASH, { ...PRO, id: 'deepseek-v5', ...requires('0.0.1') }] + json({ data: rows })(request, response) + }) + const ctx = await host(server.origin, () => token) + await ctx.plugin(Account, { baseURL: server.baseURL, modelOverrides: { 'deepseek-flash': { contextWindow: 524_288 }, 'deepseek-v5': { contextWindow: 9 } } }) + await ctx.llm.listModels('deepseek-account') + await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-flash')).resolves.toMatchObject({ context: { contextWindow: 524_288 } }) + expect(ctx.llm.listConfigurableProviders()[0]?.error).toContain('"deepseek-v5"') + await ctx.llm.listModels('deepseek-account') + expect(ctx.llm.listConfigurableProviders()[0]?.error).toContain('"deepseek-v5"') + token = 'second-account' + await ctx.llm.listModels('deepseek-account') + expect(ctx.llm.listConfigurableProviders()[0]).not.toHaveProperty('error') + await expect(ctx.llm.resolveModelInfo('deepseek-account', 'deepseek-v5')).resolves.toMatchObject({ context: { contextWindow: 9 } }) +}) + +it('rejects the retired models setting at load', async () => { + const ctx = await host('http://127.0.0.1:1') + await expect(ctx.plugin(Account, { models: [] })).rejects.toThrow(/models is no longer configurable/) + expect(ctx.llm.listProviders()).toEqual([]) +}) + +it('aborts an in-flight listing request when the plugin is disposed', async () => { + const held = Promise.withResolvers() + const server = await listing((request) => { held.resolve(request) }) + const ctx = await host(server.origin) + const account = ctx.plugin(Account, { baseURL: server.baseURL }) + await account + const pending = ctx.llm.listModels('deepseek-account') + const request = await held.promise + const closed = new Promise(resolve => request.socket.once('close', resolve)) + await account.dispose() + await closed + await expect(pending).resolves.toEqual(expect.any(Array)) +}) diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 74c0996d50..86e2664784 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -17,9 +17,16 @@ const contexts: Context[] = [] afterEach(async () => { vi.restoreAllMocks() vi.unstubAllEnvs() + vi.unstubAllGlobals() for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() }) +/** Fail the account listing request fast, so the route serves its bundled catalog and fetch mocks see only Messages traffic. */ +function listingRequest(input: string | URL | Request): Response | undefined { + const url = new URL(input instanceof Request ? input.url : input) + return url.pathname === '/models' ? new Response(null, { status: 404 }) : undefined +} + async function harness() { const ctx = new Context() contexts.push(ctx) @@ -176,7 +183,9 @@ it('propagates account sign-out to the HTTP request signal', async () => { const ctx = await harness() ctx.provide('deepseekAccount', { resolveToken: async (_url: string) => 'fixture-account-token' } as DeepSeekAccount) const started = Promise.withResolvers() - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + const listed = listingRequest(url) + if (listed !== undefined) return listed const signal = init!.signal! expect(new Headers(init!.headers).get('x-dsh-auth-token')).toBe('fixture-account-token') started.resolve(signal) @@ -222,6 +231,7 @@ it('cancels an account child without stopping its API-key parent', async () => { it('hides signed-out account models while retaining the official catalog without an API key', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') + vi.stubGlobal('fetch', vi.fn((input: string | URL | Request) => Promise.resolve(listingRequest(input) ?? new Response(null, { status: 500 })))) const ctx = await harness() let token: string | undefined ctx.provide('deepseekAccount', { resolveToken: async (url: string) => new URL(url).origin === 'https://api.deepseek.com' ? token : undefined } as DeepSeekAccount) From e647f079b909b1702b9e90936ae320ca5a2beec6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:15:29 +0800 Subject: [PATCH 009/121] fix(llm): default DeepSeek account model overrides to an empty dict --- .../ui-settings-models/tests/expected/deepseek-account-en.txt | 1 - .../ui-settings-models/tests/expected/deepseek-account-zh.txt | 1 - packages/llm/llm-deepseek-account/src/config.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt delete mode 100644 packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt deleted file mode 100644 index c9841889a6..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-en.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek Accountdeepseek-accountModelsUsing the adapter defaultsAdd modelCancelApply diff --git a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt b/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt deleted file mode 100644 index e11c2e1969..0000000000 --- a/packages/client/ui-settings-models/tests/expected/deepseek-account-zh.txt +++ /dev/null @@ -1 +0,0 @@ -DeepSeek 账号deepseek-account模型目录正在使用适配器默认模型添加模型取消保存 diff --git a/packages/llm/llm-deepseek-account/src/config.ts b/packages/llm/llm-deepseek-account/src/config.ts index a4ba75cff3..3cfdbbdde2 100644 --- a/packages/llm/llm-deepseek-account/src/config.ts +++ b/packages/llm/llm-deepseek-account/src/config.ts @@ -43,7 +43,7 @@ export const Config = z.object({ modelsURL: z.string().volatile(), modelsTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MODELS_TIMEOUT_MS).volatile(), modelsMaxAgeMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MODELS_MAX_AGE_MS).volatile(), - modelOverrides: z.dict(modelOverride).volatile(), + modelOverrides: z.dict(modelOverride).default({}).volatile(), }) /** Plain account options accepted by {@link resolveAccountOptions}. */ From 783d98b78e5619bc3f13568112b0370280584db2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 24 Sep 2026 13:15:30 +0800 Subject: [PATCH 010/121] feat(web): drop the account catalog editor from the Models page --- .../src/client/ModelsSection.tsx | 35 +++++++++++-------- .../src/client/ProviderEditor.tsx | 7 +--- .../tests/components.client.spec.tsx | 32 ++++------------- 3 files changed, 27 insertions(+), 47 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index c3eae4041b..c25cc91a26 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -463,21 +463,26 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null} - + {/* The account catalog comes from the account listing; its model overrides live in cordis.patch.yml. */} + {row.entry.provider === 'deepseek-account' + ? null + : ( + + )} {row.removable ? ( @@ -460,9 +471,14 @@ export function ModelSelect( ) })} - {state.status === 'ready' && choices.length === 0 && ( -
{t('empty.models')}
- )} + {state.status === 'ready' && choices.length === 0 && (unconfigured + ? ( +
+
{t('empty.unconfiguredTitle')}
+
{t('empty.unconfiguredHint')}
+
+ ) + :
{t('empty.models')}
)} )} diff --git a/packages/client/ui-model-selection/src/client/locales.ts b/packages/client/ui-model-selection/src/client/locales.ts index bb61b7e931..a9a67583e8 100644 --- a/packages/client/ui-model-selection/src/client/locales.ts +++ b/packages/client/ui-model-selection/src/client/locales.ts @@ -21,6 +21,8 @@ export const zh = { 'trigger.selectAria': '请选择模型', 'trigger.aria': '选择模型,当前 {model}', 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', + 'trigger.unconfigured': '未配置模型', + 'trigger.unconfiguredAria': '选择模型,当前未配置模型', 'menu.aria': '模型与推理等级', 'menu.model': '模型', 'menu.effort': '推理等级', @@ -31,6 +33,8 @@ export const zh = { 'action.reload': '重新加载', 'warning.groupLoad': '{name} 加载失败:{message}', 'empty.models': '没有可用的模型。', + 'empty.unconfiguredTitle': '还没有可用的模型', + 'empty.unconfiguredHint': '登录 DeepSeek 账号,或在「设置 → 模型」中添加 API 密钥。', 'empty.efforts': '当前模型未提供推理等级。', } satisfies Record @@ -50,6 +54,8 @@ export const en = { 'trigger.selectAria': 'Select model', 'trigger.aria': 'Select model, current {model}', 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', + 'trigger.unconfigured': 'No model configured', + 'trigger.unconfiguredAria': 'Select model, no model configured', 'menu.aria': 'Model and reasoning effort', 'menu.model': 'Model', 'menu.effort': 'Effort', @@ -60,5 +66,7 @@ export const en = { 'action.reload': 'Reload', 'warning.groupLoad': '{name} failed to load: {message}', 'empty.models': 'No models available.', + 'empty.unconfiguredTitle': 'No models available yet', + 'empty.unconfiguredHint': 'Sign in to your DeepSeek account, or add an API key in Settings → Models.', 'empty.efforts': 'This model provides no reasoning effort levels.', } satisfies Record 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 2aabbf644a..4f99f3df15 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 @@ -145,12 +145,13 @@ describe('ModelSelect reasoning effort', () => { expect(screen.queryByText('Fast catalog description')).toBeNull() }) - it.each(['model', 'provider'])('keeps the saved id and effort when the selected %s disappears', (removed) => { + it.each(['model', 'provider'])('keeps the saved id and effort when the selected %s disappears and other models remain', (removed) => { const directory = createSnapshotStore(state({ retainedEffort: 'High' })) render() expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toContain('DeepSeek-V4-Flash') + const remaining = { id: 'deepseek-account', name: 'DeepSeek Account', models: [{ id: 'deepseek-flash', name: 'DeepSeek Flash' }] } act(() => { directory.update((snapshot) => { - snapshot.groups = removed === 'provider' ? [] : snapshot.groups.map(group => ({ ...group, models: [] })) + snapshot.groups = removed === 'provider' ? [remaining] : [...snapshot.groups.map(group => ({ ...group, models: [] })), remaining] snapshot.routable = false }) }) expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) @@ -158,6 +159,27 @@ describe('ModelSelect reasoning effort', () => { expect(directory.getSnapshot().current).toEqual(state().current) }) + it('names an empty catalog as unconfigured and opens on its guidance', () => { + const directory = createSnapshotStore(state({ groups: [], routable: false, retainedEffort: 'High' })) + render() + const trigger = screen.getByRole('button', { name: zh['trigger.unconfiguredAria'] }) + expect(trigger.textContent).toBe(zh['trigger.unconfigured']) + fireEvent.click(trigger) + expect(screen.queryByRole('menuitem', { name: /模型/ })).toBeNull() + expect(screen.getByText(zh['empty.unconfiguredTitle'])).toBeTruthy() + expect(screen.getByText(zh['empty.unconfiguredHint'])).toBeTruthy() + expect(screen.queryByText(zh['empty.models'])).toBeNull() + }) + + it.each([ + { reason: 'a provider failed to load', overrides: { failures: [{ id: 'deepseek-official', name: 'DeepSeek', message: 'catalog down' }] } }, + { reason: 'the catalog failed to load', overrides: { status: 'error' as const, error: 'catalog down' } }, + ])('keeps the saved id when $reason', ({ overrides }) => { + const directory = createSnapshotStore(state({ groups: [], routable: false, ...overrides })) + render() + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('deepseek-official/deepseek-v4-flash') + }) + it('shows loading until the catalog and Session projection are both ready', async () => { const directory = createSnapshotStore(state({ current: null, @@ -532,8 +554,7 @@ it('restores the account model name after login without changing the saved route render() expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') act(() => { directory.update((snapshot) => { snapshot.groups = []; snapshot.routable = false }) }) - expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent) - .toMatchInlineSnapshot('"deepseek-account/deepseek-flashHigh"') + expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe(zh['trigger.unconfigured']) act(() => { directory.update((snapshot) => { snapshot.groups = groups; snapshot.routable = true }) }) expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') expect(directory.getSnapshot().current).toEqual(selected) From 0701068a312dc2feaefa18fb892208d6f171f200 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 24 Sep 2026 19:40:02 +0800 Subject: [PATCH 017/121] fix(ui): polish session hints, task menus and plugin examples --- .../jobs-menu-layering/geometry.expected.md | 35 ++++ .../cancelled.expected.md | 2 +- .../highlight.expected.md | 30 +++ .../another-way.expected.md | 6 +- .../plugin-install-github/mirror.expected.md | 2 +- .../session-row-hover/actions.expected.json | 46 +++++ .../session-row-hover/flat.expected.json | 47 +++++ .../session-row-hover/tree.expected.json | 47 +++++ apps/web/tests/jobs-menu-layering.e2e.ts | 185 ++++++++++++++++++ apps/web/tests/plugin-install-cancel.e2e.ts | 44 ++++- apps/web/tests/workspace-management.e2e.ts | 169 +++++++++++++++- apps/web/tsconfig.json | 1 + .../src/client/chat/TurnTriggerNodeView.tsx | 4 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- packages/client/ui-chat/src/client/locale.ts | 4 +- .../tests/turn-trigger.client.spec.tsx | 18 +- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 2 +- packages/client/ui-jobs/README.zh.md | 2 +- packages/client/ui-jobs/package.json | 4 +- .../src/client/JobListAction.module.css | 25 +-- .../ui-jobs/src/client/JobListAction.tsx | 97 ++++----- .../tests/job-list-action.client.spec.tsx | 112 +++++++---- .../src/client/PluginManagerPage.module.css | 4 +- .../ui-plugin-manager/src/client/locales.ts | 8 +- .../tests/components.client.spec.tsx | 13 ++ .../client/ui-primitives/README.i18n.yaml | 8 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../client/ui-primitives/src/HoverCard.tsx | 51 ++++- .../ui-primitives/src/HoverCardGroup.tsx | 55 ++++++ packages/client/ui-primitives/src/index.ts | 1 + .../ui-primitives/src/plugin-artwork.tsx | 23 ++- .../tests/hover-card-group.client.spec.tsx | 180 +++++++++++++++++ .../tests/hover-card.client.spec.tsx | 36 ++++ .../tests/plugin-artwork.client.spec.tsx | 14 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/rows/Rows.module.css | 6 +- .../ui-workspace/src/client/rows/Rows.tsx | 2 + .../src/client/rows/WorkspaceBrowser.tsx | 10 +- .../ui-workspace/tests/rows.client.spec.tsx | 80 +++++++- pnpm-lock.yaml | 6 + tsconfig.host.json | 1 + 47 files changed, 1235 insertions(+), 173 deletions(-) create mode 100644 apps/web/tests/expected/jobs-menu-layering/geometry.expected.md create mode 100644 apps/web/tests/expected/plugin-install-cancel/highlight.expected.md create mode 100644 apps/web/tests/expected/session-row-hover/actions.expected.json create mode 100644 apps/web/tests/expected/session-row-hover/flat.expected.json create mode 100644 apps/web/tests/expected/session-row-hover/tree.expected.json create mode 100644 apps/web/tests/jobs-menu-layering.e2e.ts create mode 100644 packages/client/ui-primitives/src/HoverCardGroup.tsx create mode 100644 packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx diff --git a/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md new file mode 100644 index 0000000000..446843f9dc --- /dev/null +++ b/apps/web/tests/expected/jobs-menu-layering/geometry.expected.md @@ -0,0 +1,35 @@ +# Background jobs above the Browser dock + +```json +{ + "collapsed": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "expanded": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "resized": { + "dockMode": "push", + "bodyMounted": true, + "fixed": true, + "anchored": true, + "viewportBounded": true, + "overlapsBrowser": true, + "aboveBrowser": true + }, + "escapeRestoresFocus": true, + "outsideDismisses": true +} +``` diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 14d7992611..b61895ca6c 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -3,7 +3,7 @@ - button "关闭" - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - textbox "包名或地址": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - text: slow-package - button "插件安装引导和示例" - button "安装源 默认安装源" diff --git a/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md new file mode 100644 index 0000000000..b56769d22f --- /dev/null +++ b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md @@ -0,0 +1,30 @@ +- listitem: + - button "查看 slow-package": slow-package + - switch "启用 slow-package" [checked] + +[ + { + "colorScheme": "light", + "reducedMotion": "no-preference", + "boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px", + "animated": true + }, + { + "colorScheme": "light", + "reducedMotion": "reduce", + "boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px", + "animated": false + }, + { + "colorScheme": "dark", + "reducedMotion": "no-preference", + "boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px", + "animated": true + }, + { + "colorScheme": "dark", + "reducedMotion": "reduce", + "boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px", + "animated": false + } +] diff --git a/apps/web/tests/expected/plugin-install-github/another-way.expected.md b/apps/web/tests/expected/plugin-install-github/another-way.expected.md index b36f699bb5..0932737ec2 100644 --- a/apps/web/tests/expected/plugin-install-github/another-way.expected.md +++ b/apps/web/tests/expected/plugin-install-github/another-way.expected.md @@ -2,14 +2,14 @@ - heading "添加插件" [level=2] - button "关闭" - textbox "插件包名": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - button "收起引导" [expanded] - button "安装源 中国大陆镜像源" - list: - listitem: - text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例: - - code: "@deepseek-ai/dsh-subagent-codex" - - button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例 + - code: dsh-plugin-whale-pet + - button "填入示例 dsh-plugin-whale-pet": 填入示例 - listitem: - text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例: - code: https://github.com/author/dsh-plugin diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md index b649252d7d..d7e9793ddb 100644 --- a/apps/web/tests/expected/plugin-install-github/mirror.expected.md +++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md @@ -2,7 +2,7 @@ - heading "添加插件" [level=2] - button "关闭" - textbox "插件包名": - - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex + - /placeholder: 例如 dsh-plugin-whale-pet - button "插件安装引导和示例" - button "安装源 中国大陆镜像源" - button "安装" [disabled] diff --git a/apps/web/tests/expected/session-row-hover/actions.expected.json b/apps/web/tests/expected/session-row-hover/actions.expected.json new file mode 100644 index 0000000000..a9bc8a1cd3 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/actions.expected.json @@ -0,0 +1,46 @@ +[ + { + "stage": "details", + "details": true, + "tooltips": [] + }, + { + "stage": "more-hover", + "details": true, + "tooltips": [] + }, + { + "stage": "archive-before-dwell", + "details": true, + "tooltips": [] + }, + { + "stage": "archive", + "details": false, + "tooltips": [ + "Archive" + ] + }, + { + "stage": "pin", + "details": false, + "tooltips": [ + "Pin" + ] + }, + { + "stage": "back-to-title", + "details": false, + "tooltips": [] + }, + { + "stage": "menu-closed", + "details": false, + "tooltips": [] + }, + { + "stage": "row-reentered", + "details": true, + "tooltips": [] + } +] diff --git a/apps/web/tests/expected/session-row-hover/flat.expected.json b/apps/web/tests/expected/session-row-hover/flat.expected.json new file mode 100644 index 0000000000..b5f75adcd6 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/flat.expected.json @@ -0,0 +1,47 @@ +[ + { + "stage": "first-before-dwell", + "first": false, + "second": false + }, + { + "stage": "first-shown", + "first": true, + "second": false + }, + { + "stage": "second-instant", + "first": false, + "second": true + }, + { + "stage": "first-instant", + "first": true, + "second": false + }, + { + "stage": "readable-portal", + "first": true, + "second": false + }, + { + "stage": "portal-return-before-dwell", + "first": false, + "second": false + }, + { + "stage": "portal-return-shown", + "first": false, + "second": true + }, + { + "stage": "list-reentry-before-dwell", + "first": false, + "second": false + }, + { + "stage": "list-reentry-shown", + "first": true, + "second": false + } +] diff --git a/apps/web/tests/expected/session-row-hover/tree.expected.json b/apps/web/tests/expected/session-row-hover/tree.expected.json new file mode 100644 index 0000000000..b5f75adcd6 --- /dev/null +++ b/apps/web/tests/expected/session-row-hover/tree.expected.json @@ -0,0 +1,47 @@ +[ + { + "stage": "first-before-dwell", + "first": false, + "second": false + }, + { + "stage": "first-shown", + "first": true, + "second": false + }, + { + "stage": "second-instant", + "first": false, + "second": true + }, + { + "stage": "first-instant", + "first": true, + "second": false + }, + { + "stage": "readable-portal", + "first": true, + "second": false + }, + { + "stage": "portal-return-before-dwell", + "first": false, + "second": false + }, + { + "stage": "portal-return-shown", + "first": false, + "second": true + }, + { + "stage": "list-reentry-before-dwell", + "first": false, + "second": false + }, + { + "stage": "list-reentry-shown", + "first": true, + "second": false + } +] diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts new file mode 100644 index 0000000000..d61e2da2ca --- /dev/null +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -0,0 +1,185 @@ +/** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, + watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED_DIR = fileURLToPath(new URL('./expected/jobs-menu-layering', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = SessionId('jobs-menu-layering-web-e2e') +// The private scaffold cwd owns the barrier; the loop bounds an orphan's lifetime. +const RELEASE = '.jobs-menu-layering.release' +const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` + +/** Measure actual overlap and hit testing; ARIA alone cannot expose clipping or stacking. */ +function placement(page: Page) { + return page.evaluate(() => { + const list = document.querySelector('ul[aria-label="Background jobs"]') + const anchor = document.querySelector('button[aria-label="1 background job running"]') + const frame = document.querySelector('[data-sidebar-browser-frame]') + const dock = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') + if (list === null || anchor === null || frame === null || dock === null) { + throw new Error('job menu, trigger, or Browser dock is missing') + } + const menu = list.closest('[data-menu-material]') ?? list + const box = menu.getBoundingClientRect() + const trigger = anchor.getBoundingClientRect() + const browser = frame.getBoundingClientRect() + const left = Math.max(box.left, browser.left) + const right = Math.min(box.right, browser.right) + const top = Math.max(box.top, browser.top) + const bottom = Math.min(box.bottom, browser.bottom) + const overlapsBrowser = right - left > 8 && bottom - top > 8 + const aboveBrowser = overlapsBrowser && [0.25, 0.5, 0.75].every(x => + [0.25, 0.5, 0.75].every(y => menu.contains(document.elementFromPoint( + left + (right - left) * x, top + (bottom - top) * y, + )))) + const expectedLeft = Math.min(Math.max(trigger.left, 12), innerWidth - box.width - 12) + const expectedTop = Math.min(Math.max(trigger.bottom + 5, 12), innerHeight - box.height - 12) + return { + dockMode: dock.dataset.sidebarRightPanel, + bodyMounted: menu.parentElement === document.body, + fixed: getComputedStyle(menu).position === 'fixed', + anchored: Math.abs(box.left - expectedLeft) <= 1 && Math.abs(box.top - expectedTop) <= 1, + viewportBounded: box.left >= 11 && box.top >= 11 + && box.right <= innerWidth - 11 && box.bottom <= innerHeight - 11, + overlapsBrowser, + aboveBrowser, + } + }) +} + +describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId | undefined + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.route('https://browser.test/**', async route => route.fulfill({ + contentType: 'text/html', body: '

Jobs menu browser fixture

', + })) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + if (scaffold !== undefined) { + try { + await writeFile(join(scaffold.workspaceCwd, RELEASE), '') + if (jobId !== undefined) { + const settled = await scaffold.ctx.jobs.wait(jobId, 15_000, agent.id) + if (settled.status === 'running' || settled.status === 'stopping') { + throw new Error('jobs menu fixture did not settle after releasing its barrier') + } + } + } catch (error) { failures.push(error) } + } + try { await browser?.close() } catch (error) { failures.push(error) } + try { await scaffold?.close() } catch (error) { failures.push(error) } + if (failures.length > 0) throw new AggregateError(failures, 'jobs menu layering teardown failed') + }) + + it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering')) + const session = page.locator(`[data-row-key="session:${SEED_ID}"]`) + await expect.poll(async () => { + if (await session.isVisible()) return true + const group = page.locator('[role="treeitem"][aria-expanded]').first() + if (await group.count() > 0 && await group.getAttribute('aria-expanded') === 'false') await group.click() + return session.isVisible() + }, { timeout: 15_000 }).toBe(true) + await session.click() + await expect.poll(() => scaffold.ctx.agents.get(SEED_ID), { timeout: 30_000 }).toBeDefined() + const opened = scaffold.ctx.agents.get(SEED_ID) + if (opened === undefined) throw new Error('opened session has no live Agent') + agent = opened + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('jobs-menu-layering-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit output and hold the jobs menu fixture', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + jobId = JobId(matched[0]) + + await page.locator('[data-sidebar-right-expand]').click() + const column = page.locator('[data-rightbar-col]') + await column.locator('[data-sidebar-right-guide-entry="browser"]').click() + const address = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' }) + await address.fill('https://browser.test/jobs-menu') + await address.press('Enter') + await page.frameLocator('[data-sidebar-browser-frame]') + .getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor() + + const trigger = page.getByRole('button', { name: '1 background job running', exact: true }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + const list = page.getByRole('list', { name: 'Background jobs' }) + await list.waitFor() + const expectedPlacement = { + dockMode: 'push', bodyMounted: true, fixed: true, anchored: true, + viewportBounded: true, overlapsBrowser: true, aboveBrowser: true, + } + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const collapsed = await placement(page) + + await list.getByRole('button', { name: /Show live output/ }).click() + await list.getByText('jobs-menu-output', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const expanded = await placement(page) + + await page.setViewportSize({ width: 1280, height: 760 }) + await expect.poll(() => placement(page)).toEqual(expectedPlacement) + const resized = await placement(page) + await list.getByRole('button', { name: /Hide live output/ }).focus() + await page.keyboard.press('Escape') + await list.waitFor({ state: 'detached' }) + const escapeRestoresFocus = await trigger.evaluate(node => node === document.activeElement) + expect(escapeRestoresFocus).toBe(true) + + await trigger.click() + await list.waitFor() + await address.click() + await list.waitFor({ state: 'detached' }) + const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false' + expect(outsideDismisses).toBe(true) + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + await mkdir(EXPECTED_DIR, { recursive: true }) + await compareOrRefreshGolden(join(EXPECTED_DIR, 'geometry.expected.md'), [ + '# Background jobs above the Browser dock', '', + '```json', + JSON.stringify({ collapsed, expanded, resized, escapeRestoresFocus, outsideDismisses }, null, 2), + '```', + ].join('\n'), MODE) + await assertFixtureInventory(EXPECTED_DIR, ['geometry.expected.md']) + }) +}) diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 9e2294051e..db282c28c5 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -9,7 +9,7 @@ import { expect, it } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' -it('cancels installation through the UI, restores files, and offers the spec again', async () => { +it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-')) const overlay = join(scratch, 'cordis.patch.yml') await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) @@ -39,6 +39,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga `) const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const tripwire = watchConsole(page) + await page.clock.install() await page.goto(scaffold.authenticatedUrl) await page.waitForSelector('[class*="frame"]') if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') @@ -135,7 +136,46 @@ it('cancels installation through the UI, restores files, and offers the spec aga await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Retry completed', { exact: true }).waitFor() expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } }) - await dialog.getByRole('button', { name: '关闭', exact: true }).click() + // Freeze the real highlight's expiry timer and CSS first frame independently. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) + const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' }) + try { + await dialog.getByRole('button', { name: '立即启用', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]') + await card.waitFor({ state: 'visible' }) + expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1) + expect(await card.getAttribute('data-plugin-status')).toBe('running') + const styles = [] + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + for (const reducedMotion of ['no-preference', 'reduce'] as const) { + await page.emulateMedia({ reducedMotion }) + const style = await card.evaluate((element) => { + const computed = getComputedStyle(element) + return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration } + }) + expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/) + expect(style.boxShadow).toContain('0px 0px 0px 2px') + if (reducedMotion === 'reduce') { + expect(style.animationName).toBe('none') + } else { + expect(style.animationName).toContain('dsh-plugin-highlight') + expect(style.animationDuration).toBe('2.4s') + } + styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' }) + } + } + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)), + `${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode()) + await page.clock.runFor(2400) + await card.waitFor({ state: 'detached' }) + } finally { + await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) }) + await page.emulateMedia({ colorScheme: null, reducedMotion: null }) + await page.clock.resume() + } await panel.getByRole('button', { name: '添加插件', exact: true }).click() await dialog.getByRole('textbox').fill('recovered-package') await page.route('**/api/pluginManager/installBundle', async (route) => { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 6f1a7f99db..d1d69727fd 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -507,21 +507,21 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo * blank Session from a deleted Workspace may also be visible, without actions. * @returns the session row locator, already present. */ - async function seededSessionRow() { - const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + async function seededSessionRow(target = page) { + const ungroupedRow = target.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') // Initial-current auto-expansion can race this gesture; converge on // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { - await page.getByText('Ungrouped', { exact: true }).click() - await page.waitForTimeout(50) + await target.getByText('Ungrouped', { exact: true }).click() + await target.waitForTimeout(50) } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') // CSS includes the actions button while it is hidden until row hover. const rows = ungroupedSection.locator('[role="treeitem"]') - .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + .filter({ has: target.locator('button[aria-label^="Session actions for "]') }) await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1) return rows.first() } @@ -561,6 +561,165 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('keeps session details dismissed after a row action until the pointer leaves the row', async () => { + const hoverPage = await newEnglishPage(browser) + try { + const consoleWatch = watchConsole(hoverPage) + await hoverPage.goto(scaffold.authenticatedUrl) + const row = await seededSessionRow(hoverPage) + const title = row.locator('[class*="title"]') + const card = hoverPage.getByRole('button', { name: `Copy: ${await title.innerText()}` }) + const tooltip = hoverPage.getByRole('tooltip') + const observations: { stage: string; details: boolean; tooltips: string[] }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, details: await card.count() > 0, tooltips: await tooltip.allTextContents() }) + } + const clockStart = Date.now() + await hoverPage.clock.install({ time: clockStart }) + await hoverPage.clock.pauseAt(clockStart + 1000) + await title.hover() + await hoverPage.clock.runFor(800) + await card.waitFor() + await observe('details') + await row.getByRole('button', { name: /^Session actions for / }).hover() + await hoverPage.clock.runFor(1000) + expect(await card.count()).toBe(1) + await observe('more-hover') + + await row.getByRole('button', { name: 'Archive session', exact: true }).hover() + await hoverPage.clock.runFor(499) + expect(await card.count()).toBe(1) + expect(await tooltip.count()).toBe(0) + await observe('archive-before-dwell') + await hoverPage.clock.runFor(1) + await tooltip.filter({ hasText: 'Archive' }).waitFor() + expect(await card.count()).toBe(0) + await observe('archive') + await row.getByRole('button', { name: 'Pin session', exact: true }).hover() + await hoverPage.clock.runFor(500) + await tooltip.filter({ hasText: 'Pin' }).waitFor() + expect(await card.count()).toBe(0) + await observe('pin') + + await title.hover() + await tooltip.waitFor({ state: 'detached' }) + // Advance beyond both the 800ms card dwell and the 500ms action tooltip dwell. + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(0) + await observe('back-to-title') + + const menu = row.getByRole('button', { name: /^Session actions for / }) + await menu.click() + await hoverPage.getByRole('menu').waitFor() + expect(await card.count()).toBe(0) + await hoverPage.keyboard.press('Escape') + await hoverPage.getByRole('menu').waitFor({ state: 'detached' }) + await title.hover() + await hoverPage.clock.runFor(1600) + expect(await card.count()).toBe(0) + await observe('menu-closed') + + await hoverPage.getByRole('button', { name: 'Settings' }).hover() + await title.hover() + await hoverPage.clock.runFor(799) + expect(await card.count()).toBe(0) + await hoverPage.clock.runFor(1) + await card.waitFor() + await observe('row-reentered') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/session-row-hover/actions.expected.json', import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await hoverPage.close() + } + }, 60_000) + + it.each(['In one list', 'Workspace Tree'])('shares the first session dwell only within the physical %s list', async (layout) => { + const hoverScaffold = await launchWebScaffold({}) + let hoverPage: Page | undefined + try { + const seed = await readFile(SEED, 'utf8') + await seedSession(hoverScaffold, seed, 'hover-first') + await seedSession(hoverScaffold, seed, 'hover-second') + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-first'), title: 'Hover first' }) + await hoverScaffold.ctx.sessionController.rename({ sessionId: SessionId('hover-second'), title: 'Hover second' }) + hoverPage = await newEnglishPage(browser) + const ui = hoverPage + const consoleWatch = watchConsole(ui) + await ui.goto(hoverScaffold.authenticatedUrl) + await ui.getByRole('button', { name: 'View options' }).click() + await ui.getByRole('menuitem', { name: layout, exact: true }).click() + if (layout !== 'In one list') { + const header = ui.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + await expect.poll(async () => { + if (await header.getAttribute('aria-expanded') !== 'true') await ui.getByText('Ungrouped', { exact: true }).click() + return header.getAttribute('aria-expanded') + }).toBe('true') + } + const first = ui.locator('[data-row-key="session:hover-first"]').getByText('Hover first', { exact: true }) + const second = ui.locator('[data-row-key="session:hover-second"]').getByText('Hover second', { exact: true }) + await first.waitFor() + await second.waitFor() + const firstCard = ui.getByRole('button', { name: 'Copy: Hover first', exact: true }) + const secondCard = ui.getByRole('button', { name: 'Copy: Hover second', exact: true }) + const observations: { stage: string; first: boolean; second: boolean }[] = [] + const observe = async (stage: string): Promise => { + observations.push({ stage, first: await firstCard.count() > 0, second: await secondCard.count() > 0 }) + } + const clockStart = Date.now() + await ui.clock.install({ time: clockStart }) + await ui.clock.pauseAt(clockStart + 1000) + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('first-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('first-shown') + await second.hover() + expect(await secondCard.count()).toBe(1) + expect(await firstCard.count()).toBe(0) + await observe('second-instant') + await first.hover() + expect(await firstCard.count()).toBe(1) + expect(await secondCard.count()).toBe(0) + await observe('first-instant') + + await firstCard.hover() + await ui.clock.runFor(500) + expect(await firstCard.isVisible()).toBe(true) + await observe('readable-portal') + await second.hover() + await ui.clock.runFor(799) + expect(await secondCard.count()).toBe(0) + await observe('portal-return-before-dwell') + await ui.clock.runFor(1) + await secondCard.waitFor() + await observe('portal-return-shown') + + await ui.getByRole('button', { name: 'Settings' }).hover() + await ui.clock.runFor(300) + await first.hover() + await ui.clock.runFor(799) + expect(await firstCard.count()).toBe(0) + await observe('list-reentry-before-dwell') + await ui.clock.runFor(1) + await firstCard.waitFor() + await observe('list-reentry-shown') + const expected = layout === 'In one list' ? 'flat' : 'tree' + await compareOrRefreshGolden( + fileURLToPath(new URL(`./expected/session-row-hover/${expected}.expected.json`, import.meta.url)), + JSON.stringify(observations, null, 2), MODE, + ) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await hoverPage?.close() + await hoverScaffold.close() + } + }, 60_000) + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) const sessionRow = await seededSessionRow() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cbd2ce227f..fa15f2e01c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -65,6 +65,7 @@ "tests/excel-opc.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", + "tests/jobs-menu-layering.e2e.ts", "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx index 7df6e718d0..afa2106ca9 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx @@ -1,7 +1,7 @@ /** An independent, expandable notice explaining a non-human Turn trigger. */ import { useId, useState, type ComponentType } from 'react' import { - IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular, + IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, type IconProps, @@ -20,7 +20,7 @@ const TRIGGER_ICONS: Record> = { subagent: IconAgentPresetOutlineRegular, github: IconBranchOutlineRegular, webhook: IconGlobeOutlineRegular, - schedule: IconAlarmClockOutlineRegular, + schedule: IconClockOutlineRegular, job: IconQueueOutlineRegular, plugin: IconCordisPluginOutlineRegular, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 3017e3eaed..1f1a0e80ca 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 8db4991626210708 - zh: e18c8aa3cb434980 + en: f8a8a59b3be4c9cc + zh: f43b4c19a3fad0a8 /chat-conversation-node-rules/display-modes: en: 42342a06ef189075 zh: 1a554b50125fcdda diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 12c63b5497..7ed10eb909 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no | Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. | | Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. | -Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. +Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify automation tasks, background work, and plugin updates. Automation task notices use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution. | Turn condition | Current behavior | |---|---| diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 341c2826a1..0fec3ea298 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 | | 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 | -触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 +触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示自动化任务、后台任务和插件更新。自动化任务通知与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。 | 轮次条件 | 当前行为 | |---|---| diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 53a4999b11..4e19bf91fd 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -57,7 +57,7 @@ export const zh = { 'message.trigger.subagent': '子任务状态更新', 'message.trigger.github': '收到 GitHub 事件', 'message.trigger.webhook': '收到外部事件', - 'message.trigger.schedule': '定时任务', + 'message.trigger.schedule': '自动化任务', 'message.trigger.job': '后台任务状态更新', 'message.trigger.plugin': '插件状态更新', 'message.trigger.explanation': '这条通知触发了本轮回复。', @@ -250,7 +250,7 @@ export const en = { 'message.trigger.subagent': 'Subtask status updated', 'message.trigger.github': 'GitHub event received', 'message.trigger.webhook': 'External event received', - 'message.trigger.schedule': 'Scheduled task', + 'message.trigger.schedule': 'Automation task', 'message.trigger.job': 'Background task updated', 'message.trigger.plugin': 'Plugin status updated', 'message.trigger.explanation': 'This notification triggered this response.', diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index 851111fd14..7737c6bb7e 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -6,7 +6,8 @@ import type { ChatNode } from '../src/client/contract/chat-nodes.ts' import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts' -import { en } from '../src/client/locale.ts' +import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -24,7 +25,7 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> { describe('Turn trigger notices', () => { it.each([ - [{ kind: 'schedule' }, 'schedule', 'Scheduled task'], + [{ kind: 'schedule' }, 'schedule', 'Automation task'], [{ kind: 'tool-jobs' }, 'job', 'Background task updated'], [{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'], [{ kind: 'goal' }, 'goal', 'Continuing goal'], @@ -45,6 +46,19 @@ describe('Turn trigger notices', () => { expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z') }) + it.each([ + { locale: en, title: 'Automation task' }, + { locale: zh, title: '自动化任务' }, + ])('uses the automation task label $title and shared clock', ({ locale, title }) => { + const node = trigger({ kind: 'schedule' }) + const view = render() + expect(view.getByRole('button').textContent).toContain(title) + const reference = render() + expect(view.getByRole('button').querySelector('svg')?.outerHTML) + .toBe(reference.container.querySelector('svg')?.outerHTML) + expect(node.data.source).toEqual({ kind: 'schedule' }) + }) + it('opens the recorded notice body and closes it independently of the Turn', () => { const view = render() const button = view.getByRole('button') diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index de3a7df885..21a7df2fd4 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -24,8 +24,8 @@ en: c34dad62fee63306 zh: 167d9da7928a9488 /deepseek-ai-dsh-client-ui-jobs/understand-the-implementation: - en: f6f5d497ab340b9e - zh: 94832761177f12ca + en: 89891fe2f5895e68 + zh: 3298efa604b2ec40 /deepseek-ai-dsh-client-ui-jobs/further-exploration: en: 01eaaecb5850f391 zh: 2eabd14cde191ab8 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 0c530b802b..86fc26b180 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
Implementation internals — click to expand -One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. +One slot entry in the header actions band (after the preset label) renders the trigger; its popover uses a body-mounted `MenuSurface` with fixed positioning so dock panels and clipping ancestors cannot cover it. The shared anchoring hook tracks scrolling, resizing, and panel-size changes. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 86ac289011..14aef41220 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器;弹出层通过挂载到 body 的 `MenuSurface` 固定定位,避免被停靠面板遮挡或被祖先容器裁剪。共享锚点 hook 跟踪滚动、窗口尺寸及面板尺寸变化。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 424df8f1c7..fb5d554d69 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -46,7 +46,9 @@ "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@types/react": "~18.3.1", - "react": "^18.2.0" + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index d51e9f7156..cf62e73fbe 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -39,31 +39,32 @@ } .menu { - position: absolute; - top: calc(100% + 5px); - left: 0; + position: fixed; z-index: 100; box-sizing: border-box; display: flex; flex-direction: column; - gap: 1px; width: 500px; max-width: min(560px, calc(100vw - 32px)); max-height: min(480px, calc(100vh - 140px)); - margin: 0; - padding: 3px; - overflow: auto; - list-style: none; - border: 0; - border-radius: var(--dsw-radius-lg); - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); + overflow: hidden; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } +.menuBody { + display: flex; + flex-direction: column; + gap: 1px; + min-height: 0; + margin: 0; + padding: 3px; + overflow: auto; + list-style: none; +} + /* Section labels over the live rows and the settled tail. */ .sectionHeader { display: flex; diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 2bca64cb3b..e326eb7f28 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,8 +1,10 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { createPortal } from 'react-dom' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer, + IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, + useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -338,10 +340,10 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) - const menuRef = useRef(null) - // Horizontal shift applied to the trigger-anchored popover so it stays - // inside the viewport (the stylesheet alone cannot see the anchor offset). - const [menuShift, setMenuShift] = useState(0) + const menuRef = useRef(null) + const menuPosition = useAnchoredPosition({ + open, anchorRef: triggerRef, panelRef: menuRef, gap: 5, margin: VIEWPORT_MARGIN, + }) const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) @@ -352,7 +354,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const settledExpanded = settledOpen ?? liveRows.length === 0 const visibleCount = liveRows.length + settledRows.length - useDismissOnOutsidePointer(rootRef, open, setOpen) + useDismissOnOutsidePointer(rootRef, open, setOpen, menuRef) // The roster follows the mounted session: one stream while this control // lives, released with it. @@ -366,32 +368,6 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, return () => { clearInterval(timer) } }, [open, liveRows.length]) - // Fit the open popover to the viewport: shift left when the anchored width - // would cross the right edge, never past the left margin. - useLayoutEffect(() => { - if (!open) { - setMenuShift(0) - return - } - const fit = (): void => { - const root = rootRef.current - const menu = menuRef.current - /* v8 ignore next -- both refs are attached while the open popover renders. */ - if (root === null || menu === null) return - const width = menu.offsetWidth - // Unlaid-out nodes (and jsdom) measure 0: keep the pure CSS anchor. - if (width === 0) return - const anchorLeft = root.getBoundingClientRect().left - setMenuShift(Math.max( - VIEWPORT_MARGIN - anchorLeft, - Math.min(0, window.innerWidth - VIEWPORT_MARGIN - width - anchorLeft), - )) - } - fit() - window.addEventListener('resize', fit) - return () => { window.removeEventListener('resize', fit) } - }, [open]) - // Observation follows visibility: the stream opens when an observable panel // expands and closes when it collapses, unmounts, or the popover closes. const expandedRow = open && expandedKey !== undefined @@ -526,32 +502,35 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, {open - ? ( -
    - {liveRows.length > 0 - ? - : null} - {liveRows.map(item)} - {settledRows.length > 0 - ? ( -
  • - - -
  • - ) - : null} - {settledExpanded ? settledRows.map(item) : null} -
+ ? createPortal( + +
    + {liveRows.length > 0 + ? + : null} + {liveRows.map(item)} + {settledRows.length > 0 + ? ( +
  • + + +
  • + ) + : null} + {settledExpanded ? settledRows.map(item) : null} +
+
, + document.body, ) : null} diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index 26b6bd43a5..8ba331cdbc 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -10,6 +10,7 @@ import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -282,41 +283,86 @@ describe('JobListAction observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('shifts an overflowing popover back inside the viewport and follows resizes', () => { + it('anchors the portaled list in viewport coordinates and follows resize and ancestor scroll', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) - vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) - const originalWidth = window.innerWidth - Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) - try { - render() - openList() - const menu = screen.getByRole('list', { name: zh['list.aria'] }) - // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. - expect(menu.style.left).toBe('-96px') + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) + const { container } = render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const anchor = vi.spyOn(trigger, 'getBoundingClientRect') + .mockReturnValue(new DOMRect(344, 40, 180, 28)) + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const menu = list.closest('[data-menu-material]') + expect(menu?.parentElement).toBe(document.body) + expect(container.contains(list)).toBe(false) + expect(menu?.style.left).toBe('248px') + expect(menu?.style.top).toBe('73px') - window.innerWidth = 900 - fireEvent(window, new Event('resize')) - // 900 - 12 - 440 - 344 = 104 > 0: the anchored position fits again. - expect(menu.style.left).toBe('0px') - } finally { - Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) - } + vi.stubGlobal('innerWidth', 900) + fireEvent(window, new Event('resize')) + expect(menu?.style.left).toBe('344px') + anchor.mockReturnValue(new DOMRect(80, 110, 180, 28)) + fireEvent.scroll(container) + expect(menu?.style.left).toBe('80px') + expect(menu?.style.top).toBe('143px') }) - it('never crosses the left viewport margin for an oversized popover', () => { - vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) - vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 4 } as DOMRect) - const originalWidth = window.innerWidth - Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) - try { - render() - openList() - const menu = screen.getByRole('list', { name: zh['list.aria'] }) - // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. - expect(menu.style.left).toBe('8px') - } finally { - Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) - } + it('keeps a near-edge anchored list inside both viewport margins', () => { + vi.stubGlobal('innerWidth', 700) + vi.stubGlobal('innerHeight', 600) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) + render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28)) + fireEvent.click(trigger) + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + .closest('[data-menu-material]') + expect(menu?.style.left).toBe('12px') + expect(menu?.style.top).toBe('468px') + }) + + it('keeps portaled controls interactive and releases observation on dismissal and unmount', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const { container, unmount } = render() + const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + trigger.focus() + fireEvent.click(trigger) + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(container.contains(list)).toBe(false) + const row = screen.getByRole('button', { + name: zh['row.expandAria'].replace('{label}', 'pnpm run build'), + }) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + expect(observe).toHaveBeenCalledTimes(1) + expect(stop).not.toHaveBeenCalled() + const kill = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build')) + fireEvent.pointerDown(kill) + fireEvent.click(kill) + expect(kill.getAttribute('data-kill-state')).toBe('armed') + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBe(list) + + row.focus() + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(document.activeElement).toBe(trigger) + expect(stop).toHaveBeenCalledTimes(1) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(2) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + expect(stop).toHaveBeenCalledTimes(2) + fireEvent.click(trigger) + expect(observe).toHaveBeenCalledTimes(3) + const reopened = screen.getByRole('list', { name: zh['list.aria'] }) + unmount() + expect(document.body.contains(reopened)).toBe(false) + expect(stop).toHaveBeenCalledTimes(3) }) it('ignores other keys while open and closes once the roster empties', () => { @@ -361,10 +407,10 @@ describe('JobListAction observation', () => { gapBefore: false, streaming: true, } - const { container } = render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(container.querySelector('[data-running]')).not.toBeNull() + expect(screen.getByRole('list', { name: zh['list.aria'] }).querySelector('[data-running]')).not.toBeNull() expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() }) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 9fafda562b..082ca33113 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -146,7 +146,7 @@ @keyframes dsh-plugin-highlight { 0%, 55% { - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); } 100% { @@ -157,7 +157,7 @@ @media (prefers-reduced-motion: reduce) { .card[data-plugin-highlight] { animation: none; - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); } } diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 3c4fb304f0..4238ee82c1 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -48,11 +48,11 @@ export const zh = { installTitle: '添加插件', installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。', installSpecLabel: '包名或地址', - installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: '例如 dsh-plugin-whale-pet', installGuideToggle: '插件安装引导和示例', installGuideHide: '收起引导', installGuideIdTitle: '包名', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。', installGuideGitTitle: 'GitHub 仓库地址', installGuideGitExample: 'https://github.com/author/dsh-plugin', @@ -241,11 +241,11 @@ export const en = { installTitle: 'Add plugin', installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.', installSpecLabel: 'Package name or address', - installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex', + installSpecPlaceholder: 'for example dsh-plugin-whale-pet', installGuideToggle: 'Install guide and examples', installGuideHide: 'Hide the guide', installGuideIdTitle: 'Package name', - installGuideIdExample: '@deepseek-ai/dsh-subagent-codex', + installGuideIdExample: 'dsh-plugin-whale-pet', installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.', installGuideGitTitle: 'GitHub repository address', installGuideGitExample: 'https://github.com/author/dsh-plugin', diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 8921b5b617..e53d35afe0 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -708,6 +708,19 @@ describe('PluginManagerPage', () => { expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) }) + it.each([ + { locale: en, placeholder: 'for example dsh-plugin-whale-pet' }, + { locale: zh, placeholder: '例如 dsh-plugin-whale-pet' }, + ])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => { + const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + setLanguage(locale) + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle })) + expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') })) + expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet') + }) + it('opens a guide under the field and drops an example into it', () => { const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) expect(screen.queryByText(en.installGuideIdHint)).toBeNull() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 43965b5496..c9387cf877 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,11 +18,11 @@ en: 91ac7c457248a593 zh: 744c9cea337bb20f /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: 33299e518ff2cad1 - zh: 880c7da903bc20d4 + en: 02587f024614dd2e + zh: 3d5e982dc612bdc7 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 83052e14c3fb34cf - zh: 0adb09885723296a + en: 73f93b4e56ea9ba1 + zh: 630ac01aad8e758a /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: d79e20306593c4bf zh: 5c0f1100e2cd6fcc diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f43b4c857b..8927707128 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -56,7 +56,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | -| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | +| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | @@ -85,6 +85,8 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. +`HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately until the pointer leaves the list. Physical exit, including entry into a portaled card, resets the shared dwell without bypassing that card's pointer grace or copy behavior. Cards without `grouped` remain independent. `dismissOnTooltip` separately opts into dismissal only when an anchor tooltip activates; the card stays dismissed until anchor exit, even after the tooltip hides. Without it, tooltip suppression remains temporary. + ### Rendering agent output The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index dbff60b8b4..469f98487d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -56,7 +56,7 @@ kind: "package-library" | `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | -| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | +| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | @@ -85,6 +85,8 @@ kind: "package-library" `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 +`HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:之后的卡片立即替换当前卡片,直到指针离开列表。离开物理列表(包括进入 portal 卡片)会重置共享等待,但不会绕过该卡片的指针宽限或复制行为。未设置 `grouped` 的卡片保持独立。`dismissOnTooltip` 可单独选择仅在锚点 tooltip 激活后关闭卡片;即使 tooltip 隐藏,卡片也保持关闭直到指针离开锚点。不设置时,tooltip 仍只暂时遮蔽卡片。 + ### 渲染 agent 输出 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 4aa1b58652..76984d1da0 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' @@ -6,6 +6,7 @@ import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { TooltipSuppression } from './Tooltip.tsx' +import { HoverCardGroupContext } from './HoverCardGroup.tsx' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -27,6 +28,8 @@ const VIEWPORT_MARGIN = 8 * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. + * @param props.grouped - share first-display dwell and exclusive display with the surrounding HoverCardGroup. + * @param props.dismissOnTooltip - a visible anchor tooltip dismisses this card until the pointer leaves the anchor. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. @@ -34,7 +37,7 @@ const VIEWPORT_MARGIN = 8 * @returns anchor wrapper with the conditional portaled card. */ export function HoverCard({ - anchor, content, openDelayMs = 500, disabled = false, + anchor, content, openDelayMs = 500, disabled = false, grouped = false, dismissOnTooltip = false, copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, }: { anchor: ReactNode @@ -43,6 +46,8 @@ export function HoverCard({ content: ReactNode openDelayMs?: number disabled?: boolean + grouped?: boolean + dismissOnTooltip?: boolean variant?: 'compact' | 'preview' widthAnchorRef?: RefObject } & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { @@ -65,6 +70,11 @@ export function HoverCard({ const positioned = pos !== null const [copied, setCopied] = useState(false) const [suppressed, setSuppressed] = useState(false) + const parentGroup = useContext(HoverCardGroupContext) + const group = grouped ? parentGroup : null + const [groupOwner] = useState(() => Symbol('hover-card')) + const dismissedForVisit = useRef(false) + const anchorSuppressed = useRef(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -83,12 +93,36 @@ export function HoverCard({ const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - const clearTimer = () => { + const clearTimer = useCallback(() => { if (timerRef.current !== null) { clearTimeout(timerRef.current) timerRef.current = null } - } + }, []) + + const dismissImmediately = useCallback(() => { + clearTimer() + cancelClose() + copyEpochRef.current += 1 + clearCopied() + setPhase('closed') + }, [clearTimer, cancelClose, clearCopied]) + const suppressionPolicy = useRef({ dismissOnTooltip, dismissImmediately }) + suppressionPolicy.current = { dismissOnTooltip, dismissImmediately } + const onTooltipSuppression = useCallback((active: boolean) => { + anchorSuppressed.current = active + setSuppressed(active) + if (active && suppressionPolicy.current.dismissOnTooltip) { + dismissedForVisit.current = true + suppressionPolicy.current.dismissImmediately() + } + }, []) + const groupVisible = phase === 'open' && positioned && !suppressed && !disabled && !dismissedForVisit.current + useLayoutEffect(() => { + if (!groupVisible || group === null) return + group.shown(groupOwner, dismissImmediately) + return () => { group.release(groupOwner) } + }, [group, groupOwner, groupVisible, dismissImmediately]) useEffect(() => { if (!closing) return @@ -269,15 +303,18 @@ export function HoverCard({ if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { - if (disabled || (inline && event.pointerType === 'touch')) return + if (disabled || (inline && event.pointerType === 'touch') + || (dismissOnTooltip && (dismissedForVisit.current || anchorSuppressed.current))) return // Coming back inside during the grace (the gap, or the card itself) // keeps the current card rather than restarting the dwell. cancelClose() if (open) { setPhase('open'); return } clearTimer() - timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) + if (group?.isWarm()) setPhase('open') + else timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) }} onPointerLeave={() => { + dismissedForVisit.current = false clearTimer() // Leaving a closed card schedules a no-op close; only arm while // open, matching Menu's shape. @@ -286,7 +323,7 @@ export function HoverCard({ onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {anchor} + {anchor} {open && !suppressed && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/src/HoverCardGroup.tsx b/packages/client/ui-primitives/src/HoverCardGroup.tsx new file mode 100644 index 0000000000..85432f99ea --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCardGroup.tsx @@ -0,0 +1,55 @@ +import { createContext, useState, type ReactNode } from 'react' + +interface GroupState { + isWarm: () => boolean + enter: () => void + leave: () => void + shown: (owner: symbol, dismiss: () => void) => void + release: (owner: symbol) => void +} + +/** Private coordination shared only by opted-in HoverCards inside one mounted group. */ +export const HoverCardGroupContext = createContext(null) + +/** + * Share the first displayed card's dwell across one physical list, without closing readable portals on list exit. + * @param props.children - list contents, including opted-in HoverCards. + * @param props.className - classes for the physical list container. + * @returns the list container and its private hover coordinator. + */ +export function HoverCardGroup({ children, className }: { children: ReactNode; className?: string }) { + const [group] = useState(() => { + let inside = false + let warm = false + let active: { owner: symbol; dismiss: () => void } | null = null + return { + isWarm: () => warm, + enter: () => { inside = true }, + leave: () => { inside = false; warm = false }, + shown: (owner, dismiss) => { + if (inside) warm = true + const previous = active + active = { owner, dismiss } + if (previous !== null && previous.owner !== owner) previous.dismiss() + }, + release: (owner) => { if (active?.owner === owner) active = null }, + } + }) + return ( + +
{ + if (event.currentTarget.contains(event.target as Node)) group.enter() + }} + onPointerOutCapture={(event) => { + // React portals bubble through this component but are outside the physical list. + if (event.currentTarget.contains(event.target as Node) + && !(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) group.leave() + }} + > + {children} +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index f853c375f7..1318703879 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -31,6 +31,7 @@ export { useAnchoredPosition } from './useAnchoredPosition.ts' export type { AnchoredPositionOptions } from './useAnchoredPosition.ts' export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' export { HoverCard } from './HoverCard.tsx' +export { HoverCardGroup } from './HoverCardGroup.tsx' export { ShortcutKeys } from './ShortcutKeys.tsx' export { observeComposition } from './keyboard-composition.ts' export { focusWithoutRing } from './focus.ts' diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index c5a774ca81..429492a489 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,13 +12,22 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Terminal plugin artwork (prompt chevron and cursor bar). */ -export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( - - - - -) +/** Terminal prompt artwork in a muted cyan-to-blue palette. */ +export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => { + const uid = useArtworkId() + return ( + + + + + + + + + + + ) +} /** Agent-loop plugin artwork (four leaves circling a center). */ export const PluginArtworkLoop = ({ size = 36, className }: IconProps) => { diff --git a/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx new file mode 100644 index 0000000000..52e59cfef2 --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card-group.client.spec.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '../src/HoverCard.tsx' +import { HoverCardGroup } from '../src/HoverCardGroup.tsx' +import { Tooltip } from '../src/Tooltip.tsx' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) + +// jsdom lacks PointerEvent; MouseEvent preserves relatedTarget for physical and React ancestry checks. +function move(from: Element, to: Element): void { + fireEvent(from, new MouseEvent('pointerout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('pointerover', { bubbles: true, relatedTarget: from })) + fireEvent(from, new MouseEvent('mouseout', { bubbles: true, relatedTarget: to })) + fireEvent(to, new MouseEvent('mouseover', { bubbles: true, relatedTarget: from })) +} +function tick(ms: number): void { act(() => { vi.advanceTimersByTime(ms) }) } +function Card({ name, grouped = true, disabled = false }: { name: string; grouped?: boolean; disabled?: boolean }) { + return {name}} + content={details {name}} copyText={name} copyLabel="Copy" copiedLabel="Copied" /> +} + +describe('HoverCardGroup', () => { + it('waits for the first actual display, then switches instantly within one physical list', () => { + render(gap) + const a = screen.getByText('A') + const b = screen.getByText('B') + const gap = screen.getByText('gap') + move(document.body, a) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + tick(1) + expect(screen.getByText('details A')).toBeTruthy() + move(a, b) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + // Repositioning the old anchor must not let it claim ownership again. + fireEvent(window, new Event('resize')) + tick(300) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.getByText('details B')).toBeTruthy() + move(b, gap) + tick(300) + move(gap, a) + expect(screen.getByText('details A')).toBeTruthy() + move(a, document.body) + tick(300) + move(document.body, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('does not warm a list from a cancelled opening or an active tooltip', () => { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(400) + move(a, b) + tick(799) + expect(screen.queryByText('details A')).toBeNull() + expect(screen.queryByText('details B')).toBeNull() + const action = screen.getByRole('button', { name: 'action B' }) + move(b, action) + // The card may open before the tooltip reaches its independent 500ms dwell. + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + move(action, document.body) + tick(300) + move(document.body, action) + tick(500) + expect(screen.getByRole('tooltip').textContent).toBe('Archive B') + tick(500) + expect(screen.queryByText('details B')).toBeNull() + move(action, a) + expect(screen.queryByText('details A')).toBeNull() + tick(800) + expect(screen.getByText('details A')).toBeTruthy() + }) + + it('resets warmth on physical portal exit without closing readable, copyable content', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn(async () => {}) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + const portal = screen.getByRole('button', { name: 'Copy: A' }) + move(a, portal) + tick(500) + expect(portal.isConnected).toBe(true) + await act(async () => { fireEvent.click(portal); await Promise.resolve() }) + expect(writeText).toHaveBeenCalledWith('A') + expect(screen.getByRole('status').textContent).toBe('Copied') + move(portal, b) + expect(screen.queryByText('details B')).toBeNull() + tick(799) + expect(screen.queryByText('details B')).toBeNull() + tick(1) + expect(screen.getByText('details B')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Copy: A' })).toBeNull() + } finally { + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('rejects stale clipboard feedback after a group handoff and return', async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + let finishWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { finishWrite = resolve })) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + try { + render() + const a = screen.getByText('A') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + fireEvent.keyDown(screen.getByRole('button', { name: 'Copy: A' }), { key: 'Enter' }) + expect(writeText).toHaveBeenCalledWith('A') + move(a, b) + expect(screen.getByText('details B')).toBeTruthy() + move(b, a) + expect(screen.getByText('details A')).toBeTruthy() + await act(async () => { finishWrite?.(); await Promise.resolve() }) + expect(screen.queryByText('Copied')).toBeNull() + expect(screen.getByText('details A')).toBeTruthy() + } finally { + finishWrite?.() + if (previous === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', previous) + } + }) + + it('keeps separate groups cold and does not enroll ungrouped cards', () => { + render(<> + ) + const a = screen.getByText('A') + const project = screen.getByText('Project') + const b = screen.getByText('B') + move(document.body, a) + tick(800) + move(a, project) + expect(screen.queryByText('details Project')).toBeNull() + tick(800) + expect(screen.getByText('details Project')).toBeTruthy() + move(project, b) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + }) + + it('releases an unmounted active card without cooling its still-mounted list', () => { + const markup = (showA: boolean, groupKey = 'first') => + {showA && } + + const view = render(markup(true)) + const a = screen.getByText('A') + move(document.body, a) + tick(800) + view.rerender(markup(false)) + move(a, screen.getByText('B')) + expect(screen.getByText('details B')).toBeTruthy() + view.rerender(markup(false, 'new')) + move(document.body, screen.getByText('B')) + expect(screen.queryByText('details B')).toBeNull() + tick(800) + expect(screen.getByText('details B')).toBeTruthy() + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 837b5762b0..7199958472 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -54,6 +54,42 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } describe('HoverCard', () => { + it.each(['disabled', 'unmounted'] as const)('keeps opt-in dismissal after a tooltip is %s without changing default suppression', (released) => { + const markup = (state: 'active' | 'disabled' | 'unmounted', content = 'card body') => row{state !== 'unmounted' && + + }} + content={
{content}
} + /> + const view = render(markup('active')) + const wrapper = screen.getByText('row').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(800) }) + const action = screen.getByRole('button', { name: 'action' }) + fireEvent.mouseEnter(action) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.getByText('card body')).toBeTruthy() + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseLeave(action) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.mouseEnter(action) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Archive') + view.rerender(markup('active', 'changed body')) + expect(screen.getByRole('tooltip').textContent).toBe('Archive') + expect(screen.queryByText('changed body')).toBeNull() + view.rerender(markup(released, 'changed body')) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(screen.queryByText('changed body')).toBeNull() + fireEvent.pointerLeave(wrapper) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(800) }) + expect(screen.getByText('changed body')).toBeTruthy() + }) + it.each(['disabled', 'unmounted'] as const)('restores its preview after a nested tooltip is %s', (released) => { const markup = (state: 'active' | 'disabled' | 'unmounted') => ( { expect(container.querySelector('foreignObject div')).not.toBeNull() }) + it('uses a muted cyan-to-blue gradient for both terminal strokes', () => { + const { container } = render() + const gradient = container.querySelector('linearGradient')! + const colors = [...container.querySelectorAll('stop')].map(stop => stop.getAttribute('stop-color')) + expect(colors).toEqual(['#4DD9D4', '#6085EB']) + expect(gradient.getAttribute('gradientUnits')).toBe('userSpaceOnUse') + const strokes = [...container.querySelectorAll('path')].map(path => path.getAttribute('stroke')) + expect(strokes).toEqual([`url(#${gradient.id})`, `url(#${gradient.id})`]) + }) + it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => { const { container } = render( <> @@ -43,8 +53,8 @@ describe('plugin artwork', () => { for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36') const ids = [...container.querySelectorAll('[id]')].map(node => node.id) expect(new Set(ids).size).toBe(ids.length) - for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) { - const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) + for (const reference of container.querySelectorAll('[fill^="url("], [stroke^="url("], [clip-path^="url("]')) { + const target = (reference.getAttribute('fill') ?? reference.getAttribute('stroke') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1) expect(ids).toContain(target) } }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 910c61850f..707280583d 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -54,8 +54,8 @@ en: 83031e7505fc228a zh: 03e17c9428fe29fd /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: - en: 34668e3c2e9c77b7 - zh: c8bb72e29a3f2490 + en: 1c352a4c1719e50a + zh: b4b9fff9ae67bfb3 /deepseek-ai-dsh-client-ui-workspace/further-exploration: en: 2abbf27abbd71ba4 zh: ace33fa2ea042357 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 6ae5be1fe5..c13bece51e 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -172,7 +172,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu ### Hover cards -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Session card in a physical list waits 800ms; after a card is displayed, moving to another Session in the same list replaces it immediately. Leaving the physical list resets that shared dwell, while a portaled card remains readable and copyable through the existing pointer grace. A row's action tooltip dismisses the card only after the tooltip's own dwell; returning to the title does not restore the card during that pointer visit. Hovering More preserves the card; opening its menu dismisses it. Workspace cards keep their independent dwell.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c3e4a18732..5e202c57c3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -172,7 +172,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 ### 悬浮卡片 -Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。 +Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Session 会立即替换卡片。离开物理列表会重置共享等待状态,而 portal 卡片仍通过原有指针宽限保持可阅读、可复制。行内操作按钮的 tooltip 只有在自身等待结束后才关闭卡片;同次指针停留中回到标题不会恢复卡片。悬停「更多」保留卡片,打开菜单才关闭。Workspace 卡片保持独立等待。
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 6bb4f294db..6d35a23113 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -252,14 +252,12 @@ flex: none; } -/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656): - bare 16px glyphs, tertiary grey; the figma gap 12 reads too airy beside - the 16px glyphs, so the buttons sit at 10. */ +/* Trailing action buttons surface on hover with bare 16px glyphs. */ .rowActions { flex: none; display: none; align-items: center; - gap: 10px; + gap: 15px; } .projectRow:hover .rowActions, diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index bdf201e7e9..028566c5f7 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -694,6 +694,8 @@ export function SessionNodeItem({ anchor={ownRow} content={} openDelayMs={800} + grouped + dismissOnTooltip disabled={menuOpen || drag?.active === true} copyText={row.blank ? undefined : row.title} copyLabel={t('copy')} diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index ddba238011..eb0db56904 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -17,7 +17,7 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular, + Button, HoverCardGroup, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular, IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular, IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular, IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular, @@ -606,7 +606,7 @@ function SessionTree({ const groupRows = rootGroups.map(group => renderGroup(group, 0)) return ( -
+ {workspaceDropAtListStart &&
+ ) } @@ -670,7 +670,7 @@ function FlatList({ } const now = Date.now() return ( -
+ -
+ ) } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index cbe570e527..66f4dc3c79 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -6,7 +6,7 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/clie import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' -import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' +import { MenuItemButton, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { MenuOpenState, SessionRowOwnerProps } from '../src/client/contract/slots.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -730,6 +730,84 @@ describe('workspace browser rows', () => { expect(onOpen).not.toHaveBeenCalled() }) + it.each([400, 800])('keeps the preview available when hovering More at %dms', (dwell) => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('more-hover'), title: 'More hover', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, + } + render() + const row = screen.getByRole('treeitem') + const title = screen.getByText('More hover') + const more = screen.getByRole('button', { name: '会话“More hover”的操作' }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(dwell) }) + fireEvent(title, new MouseEvent('pointerout', { bubbles: true, relatedTarget: more })) + fireEvent(more, new MouseEvent('pointerover', { bubbles: true, relatedTarget: title })) + act(() => { vi.advanceTimersByTime(800) }) + expect(screen.getByRole('button', { name: '复制: More hover' })).toBeTruthy() + fireEvent.click(more) + expect(screen.queryByRole('button', { name: '复制: More hover' })).toBeNull() + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it.each([0, 400, 800])('dismisses the card only after the action tooltip dwell, then until row exit (%dms)', (dwell) => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('action-hover'), title: 'Action hover', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, + } + const renderSlot: RowRenderSlot = (name: RowSlotName) => name === 'sidebar.workspaces.session.row.action' + ?