From c258d3cf4ed76a2c0449c2d5dcbf37eaf8cb7b14 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 7 Sep 2026 20:57:23 +0800 Subject: [PATCH] feat(web): use Messages for the default DeepSeek provider --- ...-09-07-deepseek-messages-adapter.i18n.yaml | 4 +- .../2026-09-07-deepseek-messages-adapter.md | 6 +- ...2026-09-07-deepseek-messages-adapter.zh.md | 6 +- apps/web/tests/deepseek-messages-chat.e2e.ts | 80 ++++++++++++++++++ .../tests/deepseek-messages-settings.e2e.ts | 57 ++++++++----- .../cards.expected.md | 9 +- .../picker.expected.md | 9 +- .../models.expected.md | 6 +- .../dismissed.expected.md | 2 +- .../tests/onboarding-usable-provider.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 34 ++++---- apps/web/tests/shipped-composition.e2e.ts | 8 +- apps/web/tests/smoke-real.e2e.ts | 82 +++++++++---------- apps/web/tsconfig.json | 1 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 4 +- packages/bundle/web-app/README.zh.md | 4 +- packages/bundle/web-app/cordis.patch.yml | 11 ++- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/DeepSeekOnboardingDialog.tsx | 6 +- .../ui-settings-models/src/client/index.ts | 2 +- .../ui-settings-models/src/client/store.ts | 4 +- .../tests/apply.client.spec.ts | 6 +- .../tests/onboarding-dialog.client.spec.tsx | 10 +-- .../tests/readiness.client.spec.ts | 4 +- .../src/client/slot-catalog.ts | 2 +- .../llm-deepseek-messages/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-messages/README.md | 2 +- .../llm/llm-deepseek-messages/README.zh.md | 2 +- .../llm/llm-deepseek-messages/src/adapter.ts | 2 +- .../llm/llm-deepseek-messages/src/index.ts | 2 +- .../tests/adapter.spec.ts | 2 +- .../deepseek-messages-chat/session.v2.jsonl | 16 ++++ .../web/deepseek-messages-chat/snapshot.yml | 7 ++ .../web/deepseek-messages-chat/ui.expected.md | 53 ++++++++++++ tsconfig.host.json | 1 + 38 files changed, 316 insertions(+), 148 deletions(-) create mode 100644 apps/web/tests/deepseek-messages-chat.e2e.ts create mode 100644 snapshots/web/deepseek-messages-chat/session.v2.jsonl create mode 100644 snapshots/web/deepseek-messages-chat/snapshot.yml create mode 100644 snapshots/web/deepseek-messages-chat/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index 3d08983f3b..1d16eeb653 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: 75a68ebcda03ea2c5850ad38c5124f81c6e9577f -2026-09-07-deepseek-messages-adapter.zh.md: c0a091e8d0b18cf90fa50d49dec48fc0500f7400 +2026-09-07-deepseek-messages-adapter.md: f0fcaf96264bd70648c59cfd3de05c1edbda40aa +2026-09-07-deepseek-messages-adapter.zh.md: 4cb2ef79eb8596955f76d4bda024926c3870c33d diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index 75a68ebcda..f0fcaf9626 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -18,7 +18,7 @@ Assistant blocks remain the durable model-visible content. A versioned `ReplayEn Image requests use bounded inline base64 versions from the attachment service. Shared attachment offload and DeepSeek token measurement keep request and measurement policy consistent. Files uploads remain outside this adapter because their endpoints and cache ownership differ from chat-completions; adding them requires a Messages-specific lifetime and error policy. -The Web profile mounts both direct adapters and leaves its selected default unchanged. Its Messages row uses `DEEPSEEK_MESSAGES_API_KEY`, so the two configuration cards can store different credentials. The shared DeepSeek editor dispatches writes by settings namespace and displays the appropriate protocol root; model identity remains provider-qualified in the composer. +The Web profile selects the Messages adapter and disables the Chat Completions adapter. Both its settings card and model group display DeepSeek, while the provider id remains `deepseek-messages` and settings remain under `llm-deepseek-messages`. First-run onboarding targets that route and reuses `DEEPSEEK_API_KEY`. Separating display names from provider ids preserves explicit protocol selection and recorded request identity. Saved selections remain user-owned; the composer switches them without rewriting Session history. ## Alternatives considered @@ -30,6 +30,6 @@ The Web profile mounts both direct adapters and leaves its selected default unch ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Default compositions retain their selected provider. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. The shared base keeps Chat Completions; the Web overlay owns the Messages default. -Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. +Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index c0a091e8d0..4cb2ef79eb 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -18,7 +18,7 @@ Status: implemented 图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。 -Web profile 同时挂载两个直接适配器,保留已选择的默认模型。Messages 行使用 `DEEPSEEK_MESSAGES_API_KEY`,使两张配置卡片可以存储不同凭据。共享 DeepSeek 编辑器按设置命名空间写入,并展示相应协议根地址;输入框中的模型标识仍包含提供方。 +Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配置卡片和模型分组均显示 DeepSeek,提供方 ID 保持为 `deepseek-messages`,设置仍位于 `llm-deepseek-messages`。首次启动引导面向该路由并复用 `DEEPSEEK_API_KEY`。显示名称与提供方 ID 分离,使协议选择和已记录的请求标识保持明确。已保存的选择仍由用户控制,输入框可以切换它们而不改写 Session 历史。 ## 考虑过的替代方案 @@ -30,6 +30,6 @@ Web profile 同时挂载两个直接适配器,保留已选择的默认模型 ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。默认组合保留已选择的提供者。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 保留 Chat Completions,Web 覆盖层拥有 Messages 默认值。 -验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 +验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/apps/web/tests/deepseek-messages-chat.e2e.ts b/apps/web/tests/deepseek-messages-chat.e2e.ts new file mode 100644 index 0000000000..f3789ede0a --- /dev/null +++ b/apps/web/tests/deepseek-messages-chat.e2e.ts @@ -0,0 +1,80 @@ +/** Recorded Web conversation through the shipped Messages route and DeepSeek model group. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/deepseek-messages-chat', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') +const MODE = webSnapshotMode() +const RECORD_PROMPT = '只回复 MESSAGES_WEB_READY,不调用工具。' + +describe('web e2e: DeepSeek Messages conversation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + deepSeekMessages: true, + ...(MODE === 'record' ? {} : { + replayFixture: FIXTURE, + paceMs: 5, + replayProviders: [{ + id: 'deepseek-messages', name: 'DeepSeek', + models: [{ + id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', + contextWindow: 1_000_000, defaultMaxTokens: 256_000, + reasoningEfforts: ['off', 'low', 'high', 'max'], defaultReasoningEffort: 'high', + }], + }], + }), + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('records the Messages provider while displaying DeepSeek in the model selector', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-chat')) + const prompts = MODE === 'record' ? [RECORD_PROMPT] : fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' }) + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByText('DeepSeek', { exact: true }).waitFor() + await page.getByRole('button', { name: /^选择模型/ }).click() + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled() + await input.fill(prompts[0]!) + await input.press('Enter') + const sessionId = await settled + const session = scaffold.ctx.sessions.get(sessionId)! + expect(session.requestHeader()?.config.provider).toBe('deepseek-messages') + await page.getByText('MESSAGES_WEB_READY', { exact: true }).waitFor() + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } else { + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + } + expect(tripwire.pageErrors).toEqual([]) + }) + + it.skipIf(MODE === 'record')('keeps the recorded-session inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 2e1784abf7..8085fab769 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -1,9 +1,9 @@ -/** Web composition: both DeepSeek protocols keep independent settings, credentials, and model identities. */ +/** Shipped Web Messages defaults, credential reuse, and recovery from a saved Chat Completions selection. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -12,16 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url)) -describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coexistence', () => { +describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages default', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - vi.stubEnv('DEEPSEEK_MESSAGES_API_KEY', undefined) - vi.stubEnv('DEEPSEEK_MESSAGES_BASE_URL', undefined) - scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true }) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) @@ -33,18 +31,21 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex try { await browser?.close() } finally { - try { await scaffold?.close() } finally { vi.unstubAllEnvs() } + await scaffold?.close() } }) - it('saves each protocol independently and exposes both model catalogs', async () => { + it('offers one DeepSeek card and saves Messages settings using the existing credential reference', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings')) + expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-messages', name: 'DeepSeek' }) + expect(scaffold.ctx.llm.listProviders().some(provider => provider.id === 'deepseek-official')).toBe(false) + expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' }) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置', exact: true }) await dialog.getByRole('button', { name: '模型', exact: true }).click() - await dialog.getByText('DeepSeek Messages', { exact: true }).waitFor() - // Unconfigured composition routes appear as setup cards together. - const messages = dialog.getByText('DeepSeek Messages', { exact: true }).locator('..').locator('..') + await dialog.getByText('DeepSeek', { exact: true }).waitFor() + expect(await dialog.getByText('DeepSeek', { exact: true }).count()).toBe(1) + const messages = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..') await messages.getByText('自定义设置', { exact: true }).click() expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder')) .toBe('https://api.deepseek.com/anthropic') @@ -54,22 +55,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic') await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash') await messages.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek Messages (deepseek-messages)。', { exact: true }).waitFor() - await dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }).click() - const original = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..') - await original.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-completions') - await original.getByText('自定义设置', { exact: true }).click() - await original.getByLabel('API 地址', { exact: true }).fill('https://completions.example/v1') - await original.getByRole('button', { name: '保存', exact: true }).click() - await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() + await dialog.getByText('已保存 DeepSeek (deepseek-messages)。', { exact: true }).waitFor() const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(settings).toContain('https://messages.example/anthropic') - expect(settings).toContain('https://completions.example/v1') + expect(settings).toContain('llm-deepseek-messages:') + expect(settings).not.toContain('llm-deepseek:') expect(settings).not.toContain('sk-e2e-') const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') - expect(credentials).toContain('DEEPSEEK_MESSAGES_API_KEY: sk-e2e-messages') - expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-completions') + expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages') + expect(credentials).not.toContain('DEEPSEEK_MESSAGES_API_KEY') expect(await page.locator('body').innerText()).not.toContain('sk-e2e-') await page.keyboard.press('Escape') await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e') @@ -80,4 +75,22 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + + it('replaces a saved Chat Completions default through the model selector', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) + await page.keyboard.press('Escape') + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await page.reload({ waitUntil: 'load' }) + const input = page.locator('[data-composer-input]').first() + await expect.poll(() => input.isEnabled()).toBe(false) + await expect.poll(() => input.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型') + await page.getByRole('button', { name: /^选择模型/ }).click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click() + await expect.poll(() => input.isEnabled()).toBe(true) + await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-messages') + const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(settings).toContain('provider: deepseek-messages') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) }) 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 fbdafd6a92..2dfe8bfcf3 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -21,14 +21,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: DeepSeek deepseek-official API 密钥 - - textbox "API 密钥": - - /placeholder: 输入 API 密钥 - - group: 自定义设置 - - button "取消" - - button "保存" - - listitem: - - text: DeepSeek Messages deepseek-messages API 密钥 + - text: DeepSeek deepseek-messages API 密钥 - textbox "API 密钥": - /placeholder: 输入 API 密钥 - group: diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index f55dba5106..aeae86376c 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,13 +1,8 @@ - menu "模型与推理等级": - group "DeepSeek": - text: DeepSeek - - menuitemradio "DeepSeek-V4-Flash" [checked]: - - text: DeepSeek-V4-Flash + - menuitemradio "Messages Flash" [checked]: + - text: Messages Flash - img - menuitemradio "DeepSeek-V4-Pro" - menuitemradio "DeepSeek-V4-Flash-Vision-Exp" - - group "DeepSeek Messages": - - text: DeepSeek Messages - - menuitemradio "Messages Flash" - - menuitemradio "DeepSeek-V4-Pro" - - menuitemradio "DeepSeek-V4-Flash-Vision-Exp" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index a302932e65..37bf3c242c 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -23,14 +23,14 @@ - listitem: - text: DeepSeek - img "API 密钥已配置" - - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: DeepSeek deepseek-official API 密钥 + - button "编辑 DeepSeek (deepseek-messages)": 编辑 + - text: DeepSeek deepseek-messages API 密钥 - textbox "API 密钥": - /placeholder: 已配置——输入新值可替换 - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: https://api.deepseek.com + - /placeholder: https://api.deepseek.com/anthropic - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 496443b057..ede298caf7 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -23,7 +23,7 @@ - listitem: - text: DeepSeek - img "API 密钥缺失" - - button "编辑 DeepSeek (deepseek-official)": 编辑 + - button "编辑 DeepSeek (deepseek-messages)": 编辑 - text: 提供方 - combobox "提供方": - option "amazon-bedrock" diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index d16ed51822..068f59156a 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -77,7 +77,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }, ).toBe(1) - await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 }) const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE) @@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- await page.getByRole('button', { name: '设置', exact: true }).click() await settings.waitFor({ timeout: 10_000 }) await settings.getByRole('button', { name: '模型' }).click() - await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 }) expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) expect((await page.content()).includes('sk-e2e-minimax')).toBe(false) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6c1ad45773..e0c879ef04 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -246,11 +246,14 @@ class RouteOnlyAdapter extends LlmAdapter { } } -function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { - if (contextWindow === undefined) return REPLAY_PROVIDERS +function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS { return REPLAY_PROVIDERS.map(provider => ({ ...provider, - models: provider.models.map(model => ({ ...model, contextWindow })), + id: messages ? 'deepseek-messages' : provider.id, + models: provider.models.map(model => ({ + ...model, + ...contextWindow === undefined ? {} : { contextWindow }, + })), })) } @@ -358,7 +361,7 @@ export interface LaunchOptions { * keyless first-run configuration lane; the default disables the adapter. */ deepSeekMissingCredential?: boolean - /** Keep the shipped Messages adapter mounted for configuration scenarios; disabled in ordinary keyless replays. */ + /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */ deepSeekMessages?: boolean /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */ welcomeNoticePending?: boolean @@ -441,6 +444,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { @@ -514,6 +518,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ + providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({ ...provider, ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }), })), @@ -776,8 +782,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ctx.llm.registerAdapter( - replayProviders(options.replayContextWindow).map(provider => provider.id), - new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), + replayProviders(options.replayContextWindow, messages).map(provider => provider.id), + new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)), ), 'web e2e scaffold: route-only adapter') } baseUrl = `http://${browserHost}:${String(port)}` diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 61acc027fd..293d074d43 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -84,7 +84,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async expect(index.headers.get('content-encoding')).toBe('gzip') expect(index.headers.get('vary')).toContain('Accept-Encoding') await index.body?.cancel() - expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(` + expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(` { "initialDelayMs": 500, "jitterRatio": 0.1, @@ -100,10 +100,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async ], } `) - await ctx.settings.update('llm-deepseek', { + await ctx.settings.update('llm-deepseek-messages', { retryPolicy: { mode: 'always', maxRetries: 5 }, }) - expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(` + expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(` { "initialDelayMs": 500, "jitterRatio": 0.1, @@ -178,7 +178,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async const commandHandle = await scaffold.ctx.agents.create({ sessionId: SessionId('shipped-command-catalog'), meta: { cwd: scaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + agentOptions: { provider: 'deepseek-messages', model: 'deepseek-v4-flash' }, }) try { expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({ diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index d5d1b0c6c8..7dd8bc34e0 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -32,6 +32,21 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url)) const authenticatedCookies = new Map>() +/** Messages SSE text, optionally truncated before content and message completion. */ +function messagesResponse(text: string, complete = true): string { + const events: Array<{ type: string; [key: string]: unknown }> = [ + { type: 'message_start', message: { id: 'msg_smoke', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }, + ] + if (complete) events.push( + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ) + return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('') +} + /** Exchange a printed process token once for Node-side HTTP/WebSocket probes. */ function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> { const existing = authenticatedCookies.get(launchUrl) @@ -392,8 +407,9 @@ describe('dsh web keyless CLI smoke', () => { writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') interface NativeProviderRequest { - messages?: { role?: string; content?: string }[] - tools?: { function?: { name?: string } }[] + system?: string + messages?: { role?: string; content?: { type?: string; text?: string }[] }[] + tools?: { name?: string }[] } let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void const requests: NativeProviderRequest[] = [] @@ -409,13 +425,7 @@ describe('dsh web keyless CLI smoke', () => { if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed) if (requests.length === 1) resolveProviderRequests(requests) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - 'data: {"choices":[{"delta":{"content":"done"}}]}', - 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + response.end(messagesResponse('done')) }) }) await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) @@ -430,7 +440,7 @@ describe('dsh web keyless CLI smoke', () => { env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-workspace', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), @@ -457,15 +467,14 @@ describe('dsh web keyless CLI smoke', () => { if (captured === undefined) { throw new Error('provider did not receive the workspace projection request') } - const workspaceMessage = captured.messages?.find(message => - message.role === 'user' && message.content?.includes('web-workspace-context-probe')) - const systemMessage = captured.messages?.find(message => message.role === 'system') + const workspaceMessage = captured.messages?.flatMap(message => message.role === 'user' ? message.content ?? [] : []) + .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe')) const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() .replace('{{webUrl}}', new URL(baseUrl).origin) - expect(systemMessage?.content).toContain(expectedWebSection) + expect(captured.system).toContain(expectedWebSection) expect(workspaceMessage).toMatchInlineSnapshot(` { - "content": " + "text": " The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. Instructions from: AGENTS.md @@ -473,10 +482,10 @@ describe('dsh web keyless CLI smoke', () => { web-workspace-context-probe ", - "role": "user", + "type": "text", } `) - expect(captured.tools?.map(tool => tool.function?.name) + expect(captured.tools?.map(tool => tool.name) .filter(name => name === 'web_search' || name === 'web_fetch')) .toMatchInlineSnapshot(` [ @@ -511,26 +520,16 @@ describe('dsh web keyless CLI smoke', () => { const mainRequest = !titleRequest && body.includes(promptMarker) response.writeHead(200, { 'content-type': 'text/event-stream' }) if (!mainRequest) { - response.end([ - 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}', - 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + response.end(messagesResponse('Web retry title')) return } mainAttempts++ if (mainAttempts === 1) { - response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n') + response.write(messagesResponse('WEB_RETRY_DISCARDED', false)) setTimeout(() => { response.destroy() }, 20) return } - response.end([ - `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`, - 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + response.end(messagesResponse(recoveredMarker)) }) }) await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) @@ -545,7 +544,7 @@ describe('dsh web keyless CLI smoke', () => { env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-retry', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_HOME: join(workspace, '.dsh'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), }, @@ -595,8 +594,8 @@ describe('dsh web keyless CLI smoke', () => { const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) interface PtcModeProviderRequest { - messages?: { role?: string; content?: string }[] - tools?: { function?: { name?: string } }[] + system?: string + tools?: { name?: string }[] } let resolveProviderRequest!: (request: PtcModeProviderRequest) => void const providerRequest = new Promise((resolve) => { @@ -609,13 +608,7 @@ describe('dsh web keyless CLI smoke', () => { request.on('end', () => { resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', - 'data: {"choices":[{"delta":{"content":"done"}}]}', - 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + response.end(messagesResponse('done')) }) }) await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) @@ -630,7 +623,7 @@ describe('dsh web keyless CLI smoke', () => { env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-ptc', - DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_TOOLS_MODE: 'ptc', DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), @@ -654,10 +647,9 @@ describe('dsh web keyless CLI smoke', () => { setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() }), ]) - expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code']) - const system = captured.messages?.find(message => message.role === 'system') - expect(system?.content).toContain('## Writing code for run_code') - expect(system?.content).toContain('declare const tools') + expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code']) + expect(captured.system).toContain('## Writing code for run_code') + expect(captured.system).toContain('declare const tools') } finally { const closed = child.exitCode === null ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f59460bd6e..012d8f2355 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -43,6 +43,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/deepseek-messages-settings.e2e.ts", + "tests/deepseek-messages-chat.e2e.ts", "tests/default-model.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 504ddd8baf..d8262aa8ab 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: fb71f5f8ac89d513619d0473349b0942d8d4cba0 -README.zh.md: 4ada766c902b402983ac05df1d2c06b41c361506 +README.md: da51f7c4506ad87dac9d5e720470a2a6aa3fb3d9 +README.zh.md: e55063fe10e274658a6c46e879bc9f2ac92b35da diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index fb71f5f8ac..da51f7c450 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -36,7 +36,9 @@ 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 clean root page. 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** offers separate DeepSeek and [DeepSeek Messages](../../llm/llm-deepseek-messages/README.md) cards. Both protocols can remain configured together; choose the provider in the model selector. +**Settings → Models** shows **DeepSeek**, backed by the [Messages adapter](../../llm/llm-deepseek-messages/README.md). The Web default is `deepseek-messages` / `deepseek-v4-flash`, using `DEEPSEEK_API_KEY`; the Web patch disables the Chat Completions adapter while other profiles retain their own composition. + +Saved model selections override the composition default. To switch a saved `deepseek-official` default or an existing conversation, select a model under **DeepSeek** in the composer; this saves the new default for later sessions without rewriting earlier request headers. Messages endpoint and model settings belong to `llm-deepseek-messages`; Chat Completions endpoint overrides are not copied. ### Configuration diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 4ada766c90..e55063fe10 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -36,7 +36,9 @@ 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 Messages](../../llm/llm-deepseek-messages/README.zh.md) 卡片。两种协议可以同时配置,在模型选择器中切换提供方。 +**设置 → 模型**显示由 [Messages 适配器](../../llm/llm-deepseek-messages/README.zh.md) 提供的 **DeepSeek**。Web 默认选择 `deepseek-messages` / `deepseek-v4-flash`,使用 `DEEPSEEK_API_KEY`;Web 补丁禁用 Chat Completions 适配器,其他 profile 保持各自组合。 + +已保存的模型选择优先于组合默认值。要切换已保存的 `deepseek-official` 默认值或已有会话,在输入框的 **DeepSeek** 分组下选择模型即可;此操作会保存后续会话的默认模型,不改写此前的请求头。Messages 端点和模型设置属于 `llm-deepseek-messages`,不会复制 Chat Completions 的端点覆盖值。 ### 配置 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 41baac9c82..1fb2c9a3c1 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -13,6 +13,14 @@ # ── surface-specific values the base deliberately omits ───────────────────── +- id: llm-deepseek + disabled: true + +- id: agent-default-model + config: + provider: deepseek-messages + model: deepseek-v4-flash + - id: system-prompt config: personaSuffix: Your working directory is {{cwd}}. @@ -42,11 +50,10 @@ # `dsh.client` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: - # Keep Messages credentials independent from the Chat Completions route. - id: llm-deepseek-messages name: '@deepseek-ai/dsh-llm-deepseek-messages' config: - apiKeyEnv: DEEPSEEK_MESSAGES_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY # Host-owned opt-in sampled when a new Web session receives its preset # delegation tools. The Plugins page edits this settings namespace. diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 842aab93fc..11bcaac355 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md -README.md: d012d9846f6a18078b3557b6c026293d999836f4 -README.zh.md: 047679cfaa23ebe119fcea88731a9063e8f7009f +README.md: bfc58338423d943ecbe42dec95ac25d1b1408cb3 +README.zh.md: d356a97a9674ae87175ba784a3786633239e6c97 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index d012d9846f..bfc5833842 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -69,7 +69,7 @@ Each settings write carries the card's current `revision`, so a concurrent write ### Onboarding coordinator -The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. +The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step targets `deepseek-messages` in `llm-deepseek-messages` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 047679cfaa..d356a97a96 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -69,7 +69,7 @@ kind: "package-reference" ### 引导协调器 -声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。 +声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek-messages` 中的 `deepseek-messages`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。 diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 3d764a1ed6..eabaa2d2d2 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -81,10 +81,10 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): } const row = state.rows.find(candidate => - candidate.entry.provider === 'deepseek-official' - && candidate.entry.settingsNs === 'llm-deepseek' + candidate.entry.provider === 'deepseek-messages' + && candidate.entry.settingsNs === 'llm-deepseek-messages' && candidate.entry.settingsPath.length === 0) - const namespace = state.namespaces.get('llm-deepseek') + const namespace = state.namespaces.get('llm-deepseek-messages') /* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */ if (row === undefined || namespace === undefined) return null diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 721f807092..c5fa2a5f27 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -147,7 +147,7 @@ export function apply(ctx: ClientContext): void { }, WelcomeNotice)) ctx.slots.inject('settings.onboarding', () => ctx.slots.register({ name: 'settings.onboarding', - id: 'deepseek-official', + id: 'deepseek-messages', order: 0, inject: deepSeekOnboardingInjected, }, DeepSeekOnboardingDialog)) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fd8eeaf9f9..e3e5abd005 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -304,8 +304,8 @@ export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadi } if (state.rows.some(providerUsable)) return { kind: 'provider-ready' } const row = state.rows.find(candidate => - candidate.entry.provider === 'deepseek-official' - && candidate.entry.settingsNs === 'llm-deepseek' + candidate.entry.provider === 'deepseek-messages' + && candidate.entry.settingsNs === 'llm-deepseek-messages' && candidate.entry.settingsPath.length === 0) if (row === undefined) return { kind: 'adapter-absent' } if (!row.entry.active) { diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 84a760e1f3..de5917620f 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -97,9 +97,9 @@ describe('ui-settings-models apply', () => { component: WelcomeNotice, options: { id: 'welcome-notice', order: -100 }, }) - const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')! + const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-messages')! expect(deepSeek.component).toBe(DeepSeekOnboardingDialog) - expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 }) + expect(deepSeek.options).toMatchObject({ id: 'deepseek-messages', order: 0 }) const deepSeekInjected = ( deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected )() @@ -245,7 +245,7 @@ describe('pushed invalidations', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') - .find(candidate => candidate.options.id === 'deepseek-official')! + .find(candidate => candidate.options.id === 'deepseek-messages')! const injected = ( entry.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 996b0ff2f4..502860dc46 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -47,7 +47,7 @@ const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPen function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView { const value = apiKeyEnv === null ? {} : { apiKeyEnv } return { - ns: 'llm-deepseek', + ns: 'llm-deepseek-messages', schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue, value, base: value, @@ -92,16 +92,16 @@ function harness(options: { return Promise.resolve(remoteOk( options.provider === false || options.providerActive === false ? [] - : [{ id: 'deepseek-official', name: 'DeepSeek' }], + : [{ id: 'deepseek-messages', name: 'DeepSeek' }], )) }, listConfigurableProviders: () => Promise.resolve(remoteOk( options.provider === false ? [] : [{ - provider: 'deepseek-official', + provider: 'deepseek-messages', displayName: 'DeepSeek', - settingsNs: options.providerSettingsNs ?? 'llm-deepseek', + settingsNs: options.providerSettingsNs ?? 'llm-deepseek-messages', settingsPath: [], }], )), @@ -138,7 +138,7 @@ function harness(options: { const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never const props: DeepSeekOnboardingDialogProps = { - stepId: 'deepseek-official', + stepId: 'deepseek-messages', complete, openSection, useSessions: unusedHook, diff --git a/packages/client/ui-settings-models/tests/readiness.client.spec.ts b/packages/client/ui-settings-models/tests/readiness.client.spec.ts index 3bea0c072d..38c13fcbf4 100644 --- a/packages/client/ui-settings-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-settings-models/tests/readiness.client.spec.ts @@ -9,9 +9,9 @@ const missingCredential: CredentialInfo = { configured: false, writable: true } function row(overrides: Partial = {}): ProviderRow { return { entry: { - provider: 'deepseek-official', + provider: 'deepseek-messages', displayName: 'DeepSeek', - settingsNs: 'llm-deepseek', + settingsNs: 'llm-deepseek-messages', settingsPath: [], active: true, }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4fa57cbf0e..e8ac09a2cb 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1673,7 +1673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-ui-settings-models WelcomeNotice id \'welcome-notice\'', - 'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'', + 'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-messages\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/llm/llm-deepseek-messages/README.i18n.yaml b/packages/llm/llm-deepseek-messages/README.i18n.yaml index 62a6da8952..3074eb5829 100644 --- a/packages/llm/llm-deepseek-messages/README.i18n.yaml +++ b/packages/llm/llm-deepseek-messages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek-messages/README.md -README.md: bad9d561a8d08e48a1ce75e9a670cbf640da4a6f -README.zh.md: 9da666d981209a28cf9f3e01b2ad3a5cfa746ce4 +README.md: 4ff26629899b4f03c64e782cb82ddaf7928b1866 +README.zh.md: 07825fa8b2e9b8534e8f7a2a7d2bd89c8423be09 diff --git a/packages/llm/llm-deepseek-messages/README.md b/packages/llm/llm-deepseek-messages/README.md index bad9d561a8..4ff2662989 100644 --- a/packages/llm/llm-deepseek-messages/README.md +++ b/packages/llm/llm-deepseek-messages/README.md @@ -27,7 +27,7 @@ Use DeepSeek through an Anthropic Messages endpoint while retaining Harness tool Mount this plugin beside `dsh-llm` in a Cordis composition and select `provider: deepseek-messages`. Model ids pass through unchanged; the catalog is advisory. -The [Web profile](../../bundle/web-app/README.md) mounts this adapter beside Chat Completions. In **Settings → Models**, configure **DeepSeek Messages**, then select its model in the composer. Web uses the independent `DEEPSEEK_MESSAGES_API_KEY` credential reference; entering a key on this card does not replace `DEEPSEEK_API_KEY`. Its endpoint and model edits apply live under `llm-deepseek-messages`. The default selected provider remains `deepseek-official`. +The [Web profile](../../bundle/web-app/README.md) uses this adapter as its default DeepSeek provider and disables Chat Completions. The **DeepSeek** card and first-run prompt use `DEEPSEEK_API_KEY`; endpoint and model edits apply live under `llm-deepseek-messages`. The provider id remains `deepseek-messages` independently of its display name. ### Minimal configuration diff --git a/packages/llm/llm-deepseek-messages/README.zh.md b/packages/llm/llm-deepseek-messages/README.zh.md index 9da666d981..07825fa8b2 100644 --- a/packages/llm/llm-deepseek-messages/README.zh.md +++ b/packages/llm/llm-deepseek-messages/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在 Cordis 组合中将本插件与 `dsh-llm` 一起挂载,并选择 `provider: deepseek-messages`。模型 ID 原样发送,目录仅供发现使用。 -[Web profile](../../bundle/web-app/README.zh.md) 同时挂载本适配器与 Chat Completions。在**设置 → 模型**中配置 **DeepSeek Messages**,然后在输入框的模型选择器中选择它的模型。Web 使用独立的 `DEEPSEEK_MESSAGES_API_KEY` 凭据引用;在此卡片输入密钥不会替换 `DEEPSEEK_API_KEY`。端点和模型修改通过 `llm-deepseek-messages` 即时生效。默认选择的提供方仍为 `deepseek-official`。 +[Web profile](../../bundle/web-app/README.zh.md) 默认使用本适配器作为 DeepSeek 提供方,并禁用 Chat Completions。**DeepSeek** 卡片和首次启动引导使用 `DEEPSEEK_API_KEY`;端点和模型修改通过 `llm-deepseek-messages` 即时生效。提供方 ID 保持为 `deepseek-messages`,与显示名称相互独立。 ### 最小配置 diff --git a/packages/llm/llm-deepseek-messages/src/adapter.ts b/packages/llm/llm-deepseek-messages/src/adapter.ts index 4c2b708a05..3f99330a5e 100644 --- a/packages/llm/llm-deepseek-messages/src/adapter.ts +++ b/packages/llm/llm-deepseek-messages/src/adapter.ts @@ -30,7 +30,7 @@ export interface AdapterDependencies { export class DeepSeekMessagesAdapter extends LlmAdapter { constructor(private readonly dependencies: AdapterDependencies) { super() } - override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek Messages' } } + override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } } override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy } override listModels(provider: string) { const connection = this.dependencies.connection() diff --git a/packages/llm/llm-deepseek-messages/src/index.ts b/packages/llm/llm-deepseek-messages/src/index.ts index 2c324f771e..1f0bb5e2b2 100644 --- a/packages/llm/llm-deepseek-messages/src/index.ts +++ b/packages/llm/llm-deepseek-messages/src/index.ts @@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config): void { return attachments === undefined ? undefined : resolveImageAttachmentAccess(attachments, path => ctx.get('fs')?.processPathFromHostPath(path), ref) }, }) - ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek Messages', settingsNs: name, settingsPath: [] }]) + ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: name, settingsPath: [] }]) const registration = ctx.llm.registerAdapter([PROVIDER], adapter) let retryPolicy = current.retryPolicy ctx.inject(['settings'], (child) => { diff --git a/packages/llm/llm-deepseek-messages/tests/adapter.spec.ts b/packages/llm/llm-deepseek-messages/tests/adapter.spec.ts index 161911c43f..305ab81019 100644 --- a/packages/llm/llm-deepseek-messages/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek-messages/tests/adapter.spec.ts @@ -49,7 +49,7 @@ describe('direct Messages HTTP', () => { 'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user', 'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1', }, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } }) - expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek Messages' }) + expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek' }) expect(await llm.listModels('deepseek-messages')).toHaveLength(3) expect(await llm.resolveModel('deepseek-messages', MODEL)).toMatchObject({ id: MODEL }) expect(llm.imageRequestPricing('deepseek-messages', MODEL)).toBeDefined() diff --git a/snapshots/web/deepseek-messages-chat/session.v2.jsonl b/snapshots/web/deepseek-messages-chat/session.v2.jsonl new file mode 100644 index 0000000000..4ded8b30ad --- /dev/null +++ b/snapshots/web/deepseek-messages-chat/session.v2.jsonl @@ -0,0 +1,16 @@ +{"type":"session","version":2,"id":"{{session:1}}","createdAt":1788785092857,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"只回复 MESSAGES_WEB_READY,不调用","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-messages","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-messages","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"MESSAGES_WEB_READY"}],"source":{"kind":"model","provider":"deepseek-messages","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}},"id":"{{message:3}}"},"usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174},"stream":[{"type":"chunk","time":1788785093863,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788785094864,"index":1,"dt":[21,0,0,0,0,22,0],"texts":["M","ESS","AGES","_W","EB","_","READ","Y"]},{"type":"chunk","time":1788785094907,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MESSAGES_WEB_READY"}}},{"type":"chunk","time":1788785094907,"chunk":{"type":"usage","usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174}}},{"type":"chunk","time":1788785094908,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/deepseek-messages-chat/snapshot.yml b/snapshots/web/deepseek-messages-chat/snapshot.yml new file mode 100644 index 0000000000..9c21f698f8 --- /dev/null +++ b/snapshots/web/deepseek-messages-chat/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: deepseek-messages-chat +profile: web +composition: web-default +recording: live +header: + class: web-default diff --git a/snapshots/web/deepseek-messages-chat/ui.expected.md b/snapshots/web/deepseek-messages-chat/ui.expected.md new file mode 100644 index 0000000000..3d2f572383 --- /dev/null +++ b/snapshots/web/deepseek-messages-chat/ui.expected.md @@ -0,0 +1,53 @@ +- banner: + - navigation "会话层级": + - button "只回复 MESSAGES_WEB_READY,不调用" [disabled] + - img + - text: 标准模式 + - button "Session 日志": + - text: Session 日志 + - img + - tablist: + - tab "对话" [selected] + - tab "轨迹" +- button "系统提示词": + - img + - img + - text: 系统提示词 +- text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}} +- button "复制": + - img +- button "已思考": + - text: 已思考 + - img +- button "思考": + - img + - img + - text: 思考 +- paragraph: MESSAGES_WEB_READY +- button "复制": + - img +- button "好的回答": + - img +- button "有问题的回答": + - img +- button "在新对话中分支": + - img +- button "用量 8.2K tok": + - img + - text: 用量 8.2K tok +- button "用时 {{duration}}": + - img + - text: 用时 {{duration}} +- text: {{clock}} +- textbox "发消息或做任务… / 调用指令 @ 文件或对话" +- button "指令": + - img +- button "添加附件": + - img +- button "访问模式,当前:工作区内修改": 工作区内修改 +- button "选择模型,当前 DeepSeek-V4-Flash,推理等级 high": + - text: DeepSeek-V4-Flash high + - img +- button "上下文已用 1%" +- button "发送消息" [disabled] +- text: 1 轮 · 1 步 LLM {{duration}} 首 token 平均 {{duration}} · {{throughput}} tok/s 缓存命中 16% 输入 8.2K tok · 输出 9 tok diff --git a/tsconfig.host.json b/tsconfig.host.json index fa3ef6b7cc..d1128d6fa2 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -34,6 +34,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/deepseek-messages-settings.e2e.ts", + "apps/web/tests/deepseek-messages-chat.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts",