Merge remote-tracking branch 'origin/master' into worktree/custom-deepseek-models

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
#	.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
#	apps/web/tests/snapshots/message-actions/ui.expected.md
#	apps/web/tests/snapshots/seeded-history/ui.expected.md
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/ModelsSection.module.css
#	packages/client/ui-models/src/client/ModelsSection.tsx
#	packages/client/ui-models/tests/components.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
Yichen Jiang
2026-07-31 14:36:00 +08:00
161 changed files with 4441 additions and 654 deletions
+2 -2
View File
@@ -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-models/README.md
README.md: bbd1ad70afd925b2c0b28f444cf8118e241a7723
README.zh.md: 99ae1e432e261def378a5fac242d24d36a870905
README.md: 97dd6cca4ba1715dc75211929c47fc6f3e632d78
README.zh.md: d142e9a94c86a4df3f49ad2ee210e24c267f92e0
+2 -2
View File
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries `baseURL` for both families, `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits. A provider row is deletable only when the user layer alone carries it (removal restores the composition base).
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface.
+2 -2
View File
@@ -2,9 +2,9 @@
[English](README.md) | 中文
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载两个家族的 `baseURL`、deepseek 的 `reasoningEffort` 或 pi-ai 的 `reasoning`,以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留。只有当某个提供方行仅由用户层承载时它才可删除(删除会还原组合 base)。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。
@@ -13,6 +13,7 @@
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
@@ -70,39 +71,11 @@
color: var(--dsw-alias-label-primary);
}
.badges {
display: inline-flex;
gap: 6px;
flex: 1;
}
.badgeOk {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--dsw-alias-state-success-primary);
font-size: 12px;
line-height: 18px;
}
.badgeOk::before {
content: '';
width: 6px;
height: 6px;
border-radius: 999px;
background: currentcolor;
}
.badgeMuted {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.rowActions {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
/* `box-sizing` on every control here: the app has no global border-box reset,
@@ -521,6 +494,19 @@ select.input {
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Icon-button label seat: named for assistive tech and for the tests that
query these controls by their text. */
.hiddenLabel {
@@ -4,14 +4,15 @@
* card at a time. A whole-section provider without a configured key (the
* unconfigured DeepSeek posture) renders as its open setup card instead of a
* row; the add flow is a card carrying the dormant-provider select. Every
* mutation writes through the wire; the page re-renders from the pushed
* invalidations or the post-apply reload.
* mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
@@ -115,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
@@ -122,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
if (changed) void controller.load()
}
const closeDelete = (): void => {
if (deleting) return
setDeleteTarget(undefined)
}
const confirmDelete = (): void => {
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
if (deleteTarget === undefined || deleting) return
setDeleting(true)
void removeProviderProfile(api, controller, deleteTarget)
.then((failure) => {
if (failure !== undefined) {
controller.fail(failure)
return
}
setDeleteTarget(undefined)
})
.finally(() => { setDeleting(false) })
}
if (state.status === 'idle') void controller.load()
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
@@ -175,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
<span className={styles['badges']}>
{row.entry.active
? <span className={styles['badgeOk']}>{t('active')}</span>
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
</span>
<span className={styles['rowActions']}>
<button
type="button"
@@ -194,11 +212,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => {
void removeProviderProfile(api, controller, target).then((failure) => {
if (failure !== undefined) controller.fail(failure)
})
}}
onClick={() => { setDeleteTarget(target) }}
>
{t('remove')}
</button>
@@ -278,6 +292,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
</button>
)}
</div>
<Modal
open={deleteTarget !== undefined}
onClose={closeDelete}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={styles['deleteDialog'] as string}
footer={(
<>
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
{t('cancel')}
</Button>
<Button
variant="outline"
className={styles['deleteConfirm']}
disabled={deleting}
onClick={confirmDelete}
>
{deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}
@@ -5,12 +5,15 @@ export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
active: 'Active',
dormant: 'Inactive',
edit: 'Edit',
remove: 'Delete',
deleteTitle: 'Delete model provider?',
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
deleteConfirm: 'Delete provider',
deleting: 'Deleting provider…',
add: 'Add provider',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
@@ -59,12 +62,15 @@ export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
active: '已启用',
dormant: '未启用',
edit: '编辑',
remove: '删除',
deleteTitle: '删除模型提供方?',
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
deleteConfirm: '删除提供方',
deleting: '正在删除提供方…',
add: '添加提供方',
provider: '提供方',
close: '关闭',
cancel: '取消',
apply: '保存',
applying: '保存中…',
@@ -48,6 +48,7 @@ describe('ui-models apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
expect(injected.t('nav')).toBe('模型')
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
@@ -73,8 +74,11 @@ describe('ui-models apply', () => {
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
b.locale.setLocale('zh')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
})
it('locale change while the slot is undeclared stays a no-op', async () => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -178,10 +178,9 @@ describe('ModelsSection', () => {
// DeepSeek has no configured credential and no stored apiKey → setup card.
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
// Configured pi-ai profiles render as rows with liveness badges only.
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.getAllByText(en.active)).toHaveLength(1)
expect(screen.getByText(en.dormant)).toBeTruthy()
expect(screen.queryByText('Active')).toBeNull()
expect(screen.queryByText('Inactive')).toBeNull()
expect(screen.getByText(en.add)).toBeTruthy()
})
@@ -606,10 +605,28 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('removes a user-added provider by unsetting its path', async () => {
it('requires confirmation before removing a user-added provider', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
expect(dialog.textContent).toContain(en.deleteDescription)
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.close }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -617,6 +634,28 @@ describe('ModelsSection', () => {
})
})
it('blocks duplicate deletion while the confirmed removal is pending', async () => {
let resolveRemoval!: (response: RpcResponse<SettingsNamespaceView>) => void
const mutate = vi.fn(() => new Promise<RpcResponse<SettingsNamespaceView>>((resolve) => {
resolveRemoval = resolve
}))
await mountSection({ mutate })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(mutate).toHaveBeenCalledOnce()
expect(confirm.disabled).toBe(true)
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
expect(mutate).toHaveBeenCalledOnce()
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
})
it('renders the load failure with a retry control', async () => {
const face = scriptedFace()
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
@@ -724,6 +763,8 @@ describe('ModelsSection', () => {
// would appear — rather than the row silently staying put.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await screen.findByText(`${en.loadFailed}: the host refused`)
})
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
describe('ModelsSection theme styles', () => {
it('uses the shared theme tokens without light-only fallbacks', () => {
expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
expect(css).toContain('background: var(--dsw-alias-bg-layer-3)')
expect(css).toContain('color: var(--dsw-alias-label-primary)')
})
})