Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
_Kerman
2026-08-05 15:34:19 +08:00
109 changed files with 2973 additions and 475 deletions

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-model/README.md
README.md: 27fb7b936b796b956f7348fa776856180350bb56
README.zh.md: 06dbcc21c31c8ed9fd72d3c07d43c8db1bcca0bb
README.md: bbc834db9489941c171aea1cb4e6dadb6f24d211
README.zh.md: 065a6b771dbd7eea87f0c632a6dd9f0fde6c0100

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single fact both entries echo; `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory. Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation seam.
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单模型仍按提供方分组所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是两个入口共同回显的唯一事实;`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService``ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单模型仍按提供方分组所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。Host 报告的提供方模型推理reasoning目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。`/model` 应用所选模型的默认推理强度composer 随后可以选择任一已公布的推理强度。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent智能体的普通模型 RPC 会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
`/client` 导出面为插件本体(`apply`/`inject`)、`ModelService``ModelDirectory` 及其状态形状、slot 注入面类型。

View File

@@ -198,8 +198,7 @@
white-space: nowrap;
}
.description,
.unlisted {
.description {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
@@ -208,10 +207,6 @@
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;

View File

@@ -174,8 +174,13 @@ export function ModelSelect(
})
}
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
const triggerAria = currentChoice === undefined
? t('trigger.selectAria')
: effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
itemRefs.current = []
let itemIndex = 0
const itemRef = () => {
@@ -189,9 +194,7 @@ export function ModelSelect(
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={effortLabel === undefined
? t('trigger.aria', { model: modelLabel })
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
aria-label={triggerAria}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
@@ -277,9 +280,6 @@ export function ModelSelect(
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}

View File

@@ -51,9 +51,7 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt
rows.push({
id: rowId(group.id, model.id),
label: model.name,
detail: model.unlisted === true
? t('option.unlisted', { group: group.name })
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
detail: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
...(directory.current.provider === group.id && directory.current.model === model.id
? { active: true } : {}),
})

View File

@@ -1,11 +1,19 @@
/** `model` namespace dictionaries. */
/**
* `model` namespace dictionaries.
*
* `trigger.selectAria` reads identically to `trigger.fallback` today and is
* still a separate key: the visible fallback label and the accessible name of
* an unset trigger are free to diverge per locale, and folding it into
* `trigger.aria` would announce the degenerate "Select model, current Select
* model".
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'command.description': '选择本会话使用的模型',
'option.unlisted': '{group} · 未列入目录',
'option.loadError': '目录加载失败:{message}',
'trigger.fallback': '选择模型',
'trigger.selectAria': '选择模型',
'trigger.aria': '选择模型,当前 {model}',
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
'menu.aria': '模型与推理等级',
@@ -16,7 +24,6 @@ export const zh = {
'error.action': '模型操作失败:{message}',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'option.currentUnlisted': '当前模型 · 未列入目录',
'empty.models': '没有可用的模型。',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
@@ -27,9 +34,9 @@ export type ModelKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'command.description': 'Select the model for this conversation',
'option.unlisted': '{group} · Not in catalog',
'option.loadError': 'Catalog failed to load: {message}',
'trigger.fallback': 'Select model',
'trigger.selectAria': 'Select model',
'trigger.aria': 'Select model, current {model}',
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
'menu.aria': 'Model and reasoning effort',
@@ -40,7 +47,6 @@ export const en = {
'error.action': 'Model operation failed: {message}',
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'option.currentUnlisted': 'Current model · Not in catalog',
'empty.models': 'No models available.',
'empty.efforts': 'This model provides no reasoning effort levels.',
} satisfies Record<ModelKey, string>

View File

@@ -111,6 +111,29 @@ describe('ModelSelect reasoning effort', () => {
.toEqual(['Default', 'Standard'])
})
it('prompts for a new selection when the current target is no longer advertised', () => {
const directory = createSnapshotStore(state({
current: { provider: 'deepseek-official', model: 'removed-model' },
}))
const select = vi.fn().mockResolvedValue(true)
render(<ModelSelect
locked={false}
available
directory={directory}
load={vi.fn()}
select={select}
t={t}
/>)
const trigger = screen.getByRole('button', { name: '选择模型' })
expect(trigger.textContent).toContain('选择模型')
fireEvent.click(trigger)
expect(screen.queryByRole('menuitem', { name: /推理等级/ })).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
expect(screen.queryByText('removed-model')).toBeNull()
expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect

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: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767
README.zh.md: a467b65da0bf0a38951cd11dba9ec54a2b03c08e
README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89
README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. 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 the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); 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.
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 DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. 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 completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model Experience
@@ -20,7 +20,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -4,11 +4,11 @@
模型设置插件:提供方配置页和按条件显示的 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`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认页面才会提交这次破坏性的 unset。
行是*已配置*的提供方(其 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 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型体验
@@ -20,7 +20,6 @@
## 已知限制与暂缓事项
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md);进阶字段(`models`、重试策略、超时……)`settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留`settings.yaml`编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -0,0 +1,364 @@
/**
* Curated editor for the direct DeepSeek adapter's advisory model catalog.
* The settings layer replaces `models` as one array, so the parent supplies
* the effective inherited rows until the first edit materializes a user
* override; reset removes that override instead of copying defaults into it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
export type DeepSeekModelDraft = Record<string, unknown>
/** The catalog fields this editor writes. */
type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens'
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/** Row index encoded in an editing-buffer key. */
function rowOf(key: string): number {
return Number(key.slice(0, key.indexOf(':')))
}
/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */
const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i
/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */
const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const
/**
* Read a typed capacity, so a user can write `256K` or `1M` instead of counting
* zeroes. The stored value stays a plain token count.
* @param text - raw field text.
* @returns the count; `undefined` when blank (inherit), `NaN` when unreadable
* (rejected by {@link validateDeepSeekModels} before any write).
*/
export function parseCapacity(text: string): number | undefined {
const trimmed = text.trim()
if (trimmed.length === 0) return undefined
const match = CAPACITY_PATTERN.exec(trimmed)
if (match === null) return Number.NaN
const suffix = match[2]?.toLowerCase()
const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1
const scaled = Number(match[1]) * scale
// A decimal multiple is exact in intent but not in binary floating point
// (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back.
const rounded = Math.round(scaled)
return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled
}
/**
* Spell a stored count back in the shortest form that survives a round trip
* through {@link parseCapacity}; a count that is not a whole number of
* thousands stays written out.
* @param value - stored capacity.
* @returns the field text.
*/
export function formatCapacity(value: number): string {
if (!Number.isInteger(value) || value <= 0) return String(value)
if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M`
if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K`
return String(value)
}
/** A localized validation failure for one user-owned model array. */
export interface DeepSeekModelsValidationFailure {
/** Zero-based model position. */
index: number
/** Message key owned by the Models settings section. */
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
| 'modelMaxTokensInvalid'
}
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
export function modelDrafts(value: unknown): DeepSeekModelDraft[] {
if (!Array.isArray(value)) return []
return value.map(entry =>
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entry as DeepSeekModelDraft
: {})
}
/**
* Validate adapter constraints that the serialized schema cannot express.
* @param value - user-owned `models` value, or undefined while inherited.
* @returns the first invalid row, or undefined when the adapter will accept it.
*/
export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined {
if (value === undefined) return undefined
const models = modelDrafts(value)
const seen = new Set<string>()
for (const [index, model] of models.entries()) {
// Compared trimmed: surrounding whitespace is a paste artifact the adapter
// would never match, and an untrimmed compare lets `model ` slip past the
// duplicate check against its own twin.
const id = model['id']
const trimmed = typeof id === 'string' ? id.trim() : undefined
if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' }
if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' }
seen.add(trimmed)
const name = model['name']
if (name !== undefined && (typeof name !== 'string' || name.length === 0)) {
return { index, key: 'modelNameInvalid' }
}
const contextWindow = model['contextWindow']
if (contextWindow !== undefined
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
return { index, key: 'modelContextInvalid' }
}
const maxTokens = model['maxTokens']
if (maxTokens !== undefined
&& (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) {
return { index, key: 'modelMaxTokensInvalid' }
}
}
return undefined
}
/** Props of {@link DeepSeekModelsEditor}. */
export interface DeepSeekModelsEditorProps {
/** Effective rows: inherited until the parent materializes an override. */
models: readonly DeepSeekModelDraft[]
/** Whether the user layer currently owns the whole array. */
overridden: boolean
/** Fallback context capacity used when a row omits its exact value. */
defaultContextWindow: number | undefined
/** Fallback output cap used when a row omits its exact value. */
defaultMaxTokens: number | undefined
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every mutation. */
disabled: boolean
/** Replace the user-owned array after one visible edit. */
onChange: (models: DeepSeekModelDraft[]) => void
/** Remove the user-owned array and return to inheritance. */
onReset: () => void
}
/**
* Render the direct DeepSeek adapter's model catalog: id and display name on
* each row, capacities behind the row's own disclosure.
* @param props - effective rows plus the array-level override actions.
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
// Capacities are edited as text, so a field's keystrokes are held here
// rather than re-derived from the parsed count on every change, which would
// rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the
// save-time rejection names a row the user can still see — which is why
// this is one entry PER FIELD: a single active buffer would be displaced by
// editing any other field, and the abandoned one would fall back to
// rendering its stored NaN as the literal `NaN`.
//
// Keys carry the row index, so the two operations that move indexes maintain
// them: `remove` re-keys around the dropped row, and reset clears them all
// because the rows they annotated are gone.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(() => new Map())
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
const update = (index: number, key: CatalogField, value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
if (at !== index) return copy
if (value === undefined) Reflect.deleteProperty(copy, key)
else copy[key] = value
return copy
})
props.onChange(next)
}
const remove = (index: number): void => {
setEditing((current) => {
const next = new Map<string, string>()
for (const [key, text] of current) {
const at = rowOf(key)
if (at === index) continue
// Only the row number moves; the field half of the key is untouched.
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, text)
}
return next
})
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at === index) continue
next.add(at > index ? at - 1 : at)
}
return next
})
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
}
const reset = (): void => {
setEditing(new Map())
setExpanded(new Set())
props.onReset()
}
const toggle = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
/** The field's text: its live keystrokes, else the stored count spelled short. */
const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => {
const typed = editing.get(`${String(index)}:${field}`)
if (typed !== undefined) return typed
const value = model[field]
return typeof value === 'number' ? formatCapacity(value) : ''
}
const settleCapacity = (index: number, field: CapacityField): void => {
const key = `${String(index)}:${field}`
const typed = editing.get(key)
if (typed === undefined) return
// Unreadable text stays on screen: the save-time rejection names a row the
// user can still see and correct.
const parsed = parseCapacity(typed)
if (parsed !== undefined && Number.isNaN(parsed)) return
setEditing((current) => {
const next = new Map(current)
next.delete(key)
return next
})
}
/** One capacity field of one row, rendered inside the row's disclosure. */
const capacityField = (
model: DeepSeekModelDraft,
index: number,
field: CapacityField,
fallback: number | undefined,
): ReactNode => (
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, field)}
placeholder={fallback === undefined
? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
: formatCapacity(fallback)}
aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
const text = event.target.value
setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
update(index, field, parseCapacity(text))
}}
onBlur={() => { settleCapacity(index, field) }}
/>
</label>
)
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
</span>
</div>
{props.overridden
? (
<button
type="button"
className={styles['linkButton']}
disabled={props.disabled}
onClick={reset}
>
{props.t('resetModels')}
</button>
)
: null}
</div>
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelList']}>
{props.models.map((model, index) => (
<div className={styles['modelEntry']} key={index}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
placeholder={props.t('modelId')}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
onBlur={(event) => {
// Settle a pasted id rather than trimming per keystroke,
// which would stop the user typing an interior space.
const trimmed = event.target.value.trim()
if (trimmed !== event.target.value) update(index, 'id', trimmed)
}}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelName')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
aria-expanded={expanded.has(index)}
title={props.t('modelAdvanced')}
onClick={() => { toggle(index) }}
>
{expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
title={props.t('removeModel')}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
{capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
{capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
</div>
)
: null}
</div>
))}
</div>
)}
<button
type="button"
className={styles['addModelButton']}
disabled={props.disabled}
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
>
<IconPlusOutline16 size={14} />
{props.t('addModel')}
</button>
</section>
)
}

View File

@@ -1,3 +1,13 @@
/* Models settings section, in the settings-panel design language: 14/22 body,
* 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense),
* 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and
* the Button/Input primitives already use.
*
* Every color resolves through a `--dsw-alias-*` token. The section used to
* name `--border` / `--surface` / `--text-*`, which nothing in this app
* defines, so it always rendered the light-mode literals written as their
* fallbacks and stayed light under the dark theme. */
.section {
display: flex;
flex-direction: column;
@@ -8,19 +18,23 @@
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 13px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
@@ -31,9 +45,11 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
/* A configured provider: outlined on the panel fill, so the filled editor
card it expands into reads as the nested object. */
.rowCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
@@ -41,7 +57,6 @@
display: flex;
flex-direction: column;
gap: 12px;
background: var(--dsw-alias-bg-layer-3);
}
.rowHead {
@@ -51,38 +66,59 @@
}
.rowName {
font-size: 15px;
font-weight: 600;
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.rowActions {
display: inline-flex;
gap: 8px;
align-items: center;
gap: 4px;
margin-left: auto;
}
.primaryButton {
/* `box-sizing` on every control here: the app has no global border-box reset,
so without it the outlined variants stand 2px taller than the filled ones
they sit beside (Cancel next to Apply, Edit next to Delete). */
.primaryButton,
.secondaryButton,
.addButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 999px;
padding: 8px 18px;
border-radius: 18px;
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.primaryButton {
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
font: inherit;
cursor: pointer;
}
.primaryButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.secondaryButton {
.secondaryButton,
.addButton {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 6px 14px;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
font: inherit;
cursor: pointer;
background: transparent;
color: var(--dsw-alias-label-primary);
}
.secondaryButton:hover:not(:disabled),
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.secondaryButton:hover:not(:disabled) {
@@ -90,12 +126,19 @@
}
.dangerButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 8px;
padding: 4px 8px;
background: none;
border-radius: 18px;
background: transparent;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
@@ -103,17 +146,43 @@
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Provider-row controls take the dense capsule (Button `.sm`). */
.rowActions .secondaryButton,
.rowActions .dangerButton {
height: 28px;
padding: 0 10px;
border-radius: 14px;
font-size: 12px;
line-height: 18px;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.5;
.dangerButton:disabled,
.addButton:disabled,
.linkButton:disabled,
.addModelButton:disabled {
opacity: 0.4;
cursor: default;
}
.primaryButton:focus-visible,
.secondaryButton:focus-visible,
.dangerButton:focus-visible,
.addButton:focus-visible,
.linkButton:focus-visible,
.addModelButton:focus-visible,
.iconButton:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Editing surface: a filled module on the panel, matching the settings
selector fill rather than adding another outline inside the row. */
.editor {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-2);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -128,11 +197,14 @@
.editorTitle {
font-size: 14px;
font-weight: 600;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.editorRoute {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
@@ -147,33 +219,36 @@
align-items: center;
gap: 10px;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.linkButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: none;
background: none;
padding: 0;
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
text-decoration: underline;
line-height: 18px;
cursor: pointer;
}
.linkButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.linkButton:disabled {
opacity: 0.5;
cursor: default;
}
.advancedHint {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
@@ -194,29 +269,12 @@
align-items: center;
gap: 6px;
align-self: flex-start;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
padding: 8px 16px;
font: inherit;
background: var(--dsw-alias-bg-layer-3);
color: inherit;
cursor: pointer;
}
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.addButton:disabled {
opacity: 0.5;
cursor: default;
}
.addCard,
.setupCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
@@ -224,9 +282,9 @@
list-style: none;
}
/* Nested in a card that already carries the module chrome. */
.addCard .editor,
.setupCard .editor {
border: none;
background: none;
padding: 0;
}
@@ -236,12 +294,44 @@
padding-top: 10px;
}
/* Native disclosure marker replaced by a rotating chevron: the built-in
triangle differs per engine and cannot take the label color. */
.customizedSummary {
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 2px 4px;
margin-left: -4px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
list-style: revert;
list-style: none;
}
.customizedSummary::-webkit-details-marker {
display: none;
}
.customizedSummary::before {
content: '';
width: 5px;
height: 5px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
transform: rotate(-45deg) translate(-1px, -1px);
transition: transform 120ms ease;
}
.customized[open] > .customizedSummary::before {
transform: rotate(45deg) translate(-1px, -1px);
}
.customizedSummary:hover {
color: var(--dsw-alias-label-primary);
}
.customizedBody {
@@ -251,17 +341,173 @@
padding-top: 12px;
}
/* Model catalog: a table, not a stack of cards. The column captions are
written once above the rows, so a row is one line of fields plus its
delete control; each field still carries the indexed `aria-label` that
names it, and the caption strip is hidden from assistive tech to keep
that name from being announced twice. */
.modelCatalog {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.modelCatalogHeading {
display: flex;
flex-direction: column;
gap: 2px;
}
.modelCatalogTitle {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.modelCatalogMeta,
.modelEmpty {
margin: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
/* Model list, shared with the pi-ai provider form (PR #1368): one bordered
entry per model, id and display name on the row, capacities behind the
row's own disclosure. The token names are this file's, not that branch's —
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
`--dsw-alias-text-primary` are undefined here and resolve to their
light-mode literals, which is the defect this section was just moved off. */
.modelList {
display: flex;
flex-direction: column;
gap: 8px;
}
.modelListHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelEntry {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px;
}
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
/* Square, label-free affordances: the row's own inputs carry the meaning, so
the actions stay glyphs and announce themselves through aria-label. */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
cursor: default;
opacity: 0.4;
}
/* The delete glyph keeps the danger tint the rest of the section uses. */
.iconButtonDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.modelAdvanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
padding: 8px 4px 2px;
}
.modelField {
display: flex;
flex-direction: column;
gap: 4px;
}
.modelFieldLabel {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.modelEmpty {
padding: 12px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 8px;
text-align: center;
}
.addModelButton {
box-sizing: border-box;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.addModelButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.input {
box-sizing: border-box;
padding: 9px 12px;
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
border-radius: 8px;
font: inherit;
font-size: 13px;
font-size: 14px;
line-height: 22px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
/* Enum pickers hold a handful of short options; a field-width dropdown reads
as a text field the user is expected to fill. */
select.input {
max-width: 240px;
cursor: pointer;
}
.input:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
@@ -271,6 +517,11 @@
color: var(--dsw-alias-label-dimmed);
}
.input:disabled {
opacity: 0.6;
cursor: default;
}
/* Select variant of .input: replaces the OS arrow (which sits flush against
the right edge) with the shared 12px chevron inset like the composer's
.select chips; the right pad reserves its cell. */
@@ -288,6 +539,7 @@
.error {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@@ -303,3 +555,20 @@
.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 {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.customizedSummary::before {
transition: none;
}
}

View File

@@ -5,19 +5,23 @@
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
* pi-ai). Everything else stays owned by `settings.yaml`. Profile edits land as
* minimal `settings.mutate` path ops against the stored section — the card
* reads the redacted descriptor, so it names only the fields it can see and a
* stored literal secret is never collaterally removed.
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
* DeepSeek's id/name/context-window model catalog). Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card reads the redacted
* descriptor, so it names only the fields it can see and a stored literal
* secret is never collaterally removed.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-client-connection/client'
import {
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -179,6 +183,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
@@ -229,6 +239,18 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const keyLocked = keyState?.writable === false
/**
* The catalog beneath the user layer: what the composition entry pinned, or
* else the schema default that `resolve` would supply. The effective value
* cannot answer this — it still carries the stored override until the unset
* is applied, so reading it would echo that override straight back the
* moment reset drops it, leaving the rows unchanged until a reload.
*/
const inheritedModels = (): unknown => {
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
}
/**
* The curated fields of one known adapter family. Taking the narrowed
* family as a parameter is what makes `EFFORT_FIELD` total here: an
@@ -236,6 +258,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
const effortField = EFFORT_FIELD[family]
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
return (
<>
<div className={styles['field']}>
@@ -289,6 +316,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
</div>
</details>
</>

View File

@@ -30,6 +30,27 @@ export const en = {
baseUrlDefault: 'Provider default',
effort: 'Reasoning effort',
effortInherit: 'Default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
resetModels: 'Restore defaults',
model: 'Model',
modelId: 'Model ID',
modelName: 'Display name',
modelNamePlaceholder: 'Uses the model ID when empty',
contextWindow: 'Context window',
contextWindowPlaceholder: 'Uses the provider default',
maxTokens: 'Max output tokens',
maxTokensPlaceholder: 'Uses the provider default',
modelAdvanced: 'Capacities',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
modelIdRequired: 'Model ID is required.',
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
@@ -70,6 +91,27 @@ export const zh: typeof en = {
baseUrlDefault: '提供方默认',
effort: '推理强度',
effortInherit: '默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
resetModels: '恢复默认模型',
model: '模型',
modelId: '模型 ID',
modelName: '显示名称',
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
maxTokens: '最大输出 token 数',
maxTokensPlaceholder: '使用提供方默认值',
modelAdvanced: '容量',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',

View File

@@ -8,6 +8,9 @@ import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import {
DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
} from '../src/client/DeepSeekModelsEditor.tsx'
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
import type { ProviderRow } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
@@ -16,6 +19,16 @@ afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
/** Open one row's capacity disclosure (1-based, as the labels read). */
function expandRow(position: number): void {
fireEvent.click(screen.getByLabelText(`${en.modelAdvanced} ${String(position)}`))
}
/** The capacity inputs of every open row, in row order. */
function capacityInputs(label: string): HTMLInputElement[] {
return screen.getAllByLabelText<HTMLInputElement>(new RegExp(label))
}
const PiAiConfig = Schema.object({
token: Schema.string().role('secret'),
providers: Schema.dict(Schema.object({
@@ -32,15 +45,54 @@ const DeepSeekConfig = Schema.object({
apiKeyEnv: Schema.string().role('credential-ref'),
baseURL: Schema.string().pattern(/^https:\/\//),
reasoningEffort: Schema.union(['off', 'high', 'max']),
defaultContextWindow: Schema.number().step(1).min(1),
models: Schema.array(Schema.object({
id: Schema.string().required(),
name: Schema.string(),
description: Schema.string(),
contextWindow: Schema.number().step(1).min(1),
// The adapter declares its catalog as a schema default rather than a
// composition entry, which is what the restore-defaults path has to read.
})).default([
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: '',
contextWindow: 1_000_000,
},
{
id: 'deepseek-v4-pro',
name: 'DeepSeek-V4-Pro',
description: '',
contextWindow: 1_000_000,
},
]),
})
const DEFAULT_DEEPSEEK_MODELS = [
{
id: 'deepseek-v4-flash',
name: 'DeepSeek-V4-Flash',
description: 'Preserved hidden detail',
contextWindow: 1_000_000,
},
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 1_000_000 },
]
function wireNamespaces(): SettingsNamespaceView[] {
return [
{
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
base: {},
value: {
apiKeyEnv: 'DEEPSEEK_API_KEY',
baseURL: 'https://base',
reasoningEffort: 'high',
defaultContextWindow: 1_000_000,
maxTokens: 256_000,
models: DEFAULT_DEEPSEEK_MODELS,
},
base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
user: { reasoningEffort: 'high' },
applies: 'live',
secrets: [{ path: ['apiKey'], set: false }],
@@ -244,6 +296,388 @@ describe('ModelsSection', () => {
})
})
it('materializes inherited models and adds an arbitrary DeepSeek id', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
fireEvent.click(screen.getByText(en.addModel))
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
const names = screen.getAllByLabelText(new RegExp(en.modelName))
expandRow(3)
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } })
fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } })
// Only row 3 is open, so its capacity is addressed by its own label.
fireEvent.change(screen.getByLabelText(`${en.contextWindow} 3`), { target: { value: '131072' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
...DEFAULT_DEEPSEEK_MODELS,
{ id: 'private-preview', name: 'Private Preview', contextWindow: 131_072 },
],
}],
expectedRevision: 0,
})
})
it('rejects duplicate DeepSeek model ids before writing', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getByText(en.addModel))
const ids = screen.getAllByLabelText(new RegExp(en.modelId))
fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'deepseek-v4-flash' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(`Model 3: ${en.modelIdDuplicate}`)
expect(mutate).not.toHaveBeenCalled()
})
it('validates every adapter-owned model catalog invariant', () => {
expect(modelDrafts(undefined)).toEqual([])
expect(modelDrafts([null, 'bad', { id: 'ok' }])).toEqual([{}, {}, { id: 'ok' }])
expect(validateDeepSeekModels([{}])).toEqual({ index: 0, key: 'modelIdRequired' })
expect(validateDeepSeekModels([{ id: 'same' }, { id: 'same' }]))
.toEqual({ index: 1, key: 'modelIdDuplicate' })
expect(validateDeepSeekModels([{ id: 'model', name: '' }]))
.toEqual({ index: 0, key: 'modelNameInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: null }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1.5 }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 0 }]))
.toEqual({ index: 0, key: 'modelContextInvalid' })
expect(validateDeepSeekModels([{ id: 'model', contextWindow: 1 }])).toBeUndefined()
expect(validateDeepSeekModels([{ id: 'model', maxTokens: null }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 1.5 }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 0 }]))
.toEqual({ index: 0, key: 'modelMaxTokensInvalid' })
expect(validateDeepSeekModels([{ id: 'model', maxTokens: 8192 }])).toBeUndefined()
})
it('reads context windows written as counts, thousands, or millions', () => {
expect(parseCapacity('')).toBeUndefined()
expect(parseCapacity(' ')).toBeUndefined()
expect(parseCapacity('131072')).toBe(131_072)
expect(parseCapacity(' 256K ')).toBe(256_000)
expect(parseCapacity('256k')).toBe(256_000)
expect(parseCapacity('1M')).toBe(1_000_000)
expect(parseCapacity('1m')).toBe(1_000_000)
// 1M is 1000K, not 1024K: capacities are quoted in decimal.
expect(parseCapacity('1M')).toBe(parseCapacity('1000K'))
// 2.3 * 1e6 is a few ULPs high in binary floating point; an integral
// intent must not become a fractional count the validator rejects.
expect(parseCapacity('2.3M')).toBe(2_300_000)
expect(Number.isInteger(parseCapacity('1.5M'))).toBe(true)
// A genuinely fractional count survives as one, for the validator to reject.
expect(parseCapacity('0.0001K')).toBeCloseTo(0.1)
expect(parseCapacity('abc')).toBeNaN()
expect(parseCapacity('1G')).toBeNaN()
expect(parseCapacity('1M1')).toBeNaN()
})
it('spells a stored count in the shortest form that round-trips', () => {
expect(formatCapacity(1_000_000)).toBe('1M')
expect(formatCapacity(256_000)).toBe('256K')
expect(formatCapacity(1_500_000)).toBe('1500K')
expect(formatCapacity(131_072)).toBe('131072')
// Values the validator will reject are shown as-is rather than dressed up.
expect(formatCapacity(Number.NaN)).toBe('NaN')
expect(formatCapacity(0)).toBe('0')
for (const text of ['1M', '256K', '131072', '1500K']) {
expect(formatCapacity(parseCapacity(text) as number)).toBe(text)
}
})
it('accepts a suffixed context window and stores the plain count', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
// The inherited 1000000 reads back short.
expect((windows[0] as HTMLInputElement).value).toBe('1M')
// Keystrokes stay verbatim while the row has focus, so typing `1000` does
// not rewrite itself to `1K` mid-word.
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000')
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1000K' } })
expect((windows[0] as HTMLInputElement).value).toBe('1000K')
// Blur settles the row to the canonical spelling of the same count.
fireEvent.blur(windows[0] as HTMLInputElement)
expect((windows[0] as HTMLInputElement).value).toBe('1M')
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '256K' } })
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
{ ...DEFAULT_DEEPSEEK_MODELS[0], contextWindow: 1_000_000 },
{ ...DEFAULT_DEEPSEEK_MODELS[1], contextWindow: 256_000 },
],
}],
expectedRevision: 0,
})
})
it('keeps unreadable context-window text on screen and refuses the write', async () => {
const { mutate } = await mountSection()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '1 gazillion' } })
// Blurring a row that is not the edited one leaves the buffer alone.
fireEvent.blur(windows[1] as HTMLInputElement)
fireEvent.blur(windows[0] as HTMLInputElement)
// The text the user typed is still there to correct.
expect((windows[0] as HTMLInputElement).value).toBe('1 gazillion')
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(`Model 1: ${en.modelContextInvalid}`)
expect(mutate).not.toHaveBeenCalled()
})
it.each([
['the schema default', undefined],
['the composition entry', { models: [{ id: 'pinned-by-deployment' }] }],
])('restores %s the moment the override is dropped, not after a reload', async (_label, base) => {
// The regression: reset read the EFFECTIVE value, which still carries the
// stored override until the unset is applied — so the rows did not change
// and the catalog only looked restored after reopening the card.
const { face } = scriptedFace()
const stored = { models: [{ id: 'user-only-model', name: 'User Only' }] }
const overridden: SettingsNamespaceView = {
ns: 'llm-deepseek',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
value: { ...stored, defaultContextWindow: 1_000_000 },
...base === undefined ? {} : { base },
user: stored,
applies: 'live',
secrets: [],
revision: 0,
}
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
render(<ProviderEditor
provider="deepseek-official"
displayName="DeepSeek"
namespace={overridden}
settingsPath={[]}
api={face as never}
t={t}
readOnly={false}
onClose={() => {}}
/>)
fireEvent.click(screen.getByText(en.customized))
expect(screen.getByText(en.modelsCustomized)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(['user-only-model'])
fireEvent.click(screen.getByText(en.resetModels))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
expect(screen.getAllByLabelText(new RegExp(en.modelId)).map(input => (input as HTMLInputElement).value))
.toEqual(base === undefined ? ['deepseek-v4-flash', 'deepseek-v4-pro'] : ['pinned-by-deployment'])
})
it('keeps every row\'s unreadable text, not just the last one edited', async () => {
// The regression: one active buffer meant editing a second row displaced
// the first, which then fell back to rendering its stored NaN as `NaN` —
// losing the text the user was told they could still correct.
await mountSection()
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'not a number' } })
fireEvent.blur(windows[0] as HTMLInputElement)
fireEvent.change(windows[1] as HTMLInputElement, { target: { value: '2M' } })
expect((windows[0] as HTMLInputElement).value).toBe('not a number')
expect((windows[1] as HTMLInputElement).value).toBe('2M')
})
it('re-keys the typed text around a removed row', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.customized))
const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow)
const removeRow = (at: number): void => {
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[at] as HTMLElement)
}
// Three rows, with text parked on the outer two.
fireEvent.click(screen.getByText(en.addModel))
expandRow(1)
expandRow(2)
expandRow(3)
fireEvent.change(windows()[0] as HTMLInputElement, { target: { value: 'top text' } })
fireEvent.blur(windows()[0] as HTMLInputElement)
fireEvent.change(windows()[2] as HTMLInputElement, { target: { value: 'bottom text' } })
fireEvent.blur(windows()[2] as HTMLInputElement)
// Dropping the middle row leaves the row above untouched and carries the
// row below down with its own text, rather than stranding it.
removeRow(1)
expect(windows()).toHaveLength(2)
expect((windows()[0] as HTMLInputElement).value).toBe('top text')
expect((windows()[1] as HTMLInputElement).value).toBe('bottom text')
// Dropping a row that holds text takes that text with it; the survivor
// keeps its own rather than inheriting the deleted row's.
removeRow(0)
expect(windows()).toHaveLength(1)
expect((windows()[0] as HTMLInputElement).value).toBe('bottom text')
})
it('drops the typed text when reset replaces the rows it annotated', async () => {
// The regression: reset removed the override but left the buffer, so an
// inherited row displayed text no settings layer stores — and because an
// unreadable buffer never settles, it stayed there indefinitely.
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: 'garbage' } })
fireEvent.blur(windows[0] as HTMLInputElement)
fireEvent.click(screen.getByText(en.resetModels))
// Reset collapses every row, so the restored capacity needs opening again.
expandRow(1)
const restored = capacityInputs(en.contextWindow)
expect((restored[0] as HTMLInputElement).value).toBe('1M')
// Reset put the draft back where it started, so Apply writes nothing at
// all rather than persisting whatever the stale text had parsed to.
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() })
expect(mutate).not.toHaveBeenCalled()
})
it('edits an output cap per model and carries its text across a removal', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
expandRow(1)
expandRow(2)
// The profile's own cap is the placeholder both rows inherit.
expect(capacityInputs(en.maxTokens).map(input => input.placeholder)).toEqual(['256K', '256K'])
fireEvent.change(screen.getByLabelText(`${en.maxTokens} 2`), { target: { value: '64K' } })
fireEvent.blur(screen.getByLabelText(`${en.maxTokens} 2`))
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 2`).value).toBe('64K')
// Dropping the row above carries the cap text down with its own row.
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).value).toBe('64K')
// The disclosure closes on a second press.
expandRow(1)
expect(screen.queryByLabelText(`${en.maxTokens} 1`)).toBeNull()
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [{ ...DEFAULT_DEEPSEEK_MODELS[1], maxTokens: 64_000 }],
}],
expectedRevision: 0,
})
})
it('settles a pasted id and refuses whitespace that would never match', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.customized))
const ids = screen.getAllByLabelText<HTMLInputElement>(new RegExp(en.modelId))
fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } })
fireEvent.blur(ids[0] as HTMLInputElement)
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
// A settled id needs no second trim.
fireEvent.blur(ids[0] as HTMLInputElement)
expect((ids[0] as HTMLInputElement).value).toBe('deepseek-v4-flash')
// An id that is only whitespace is as absent as an empty one, and a padded
// id no longer slips past the duplicate check against its own twin.
expect(validateDeepSeekModels([{ id: ' ' }])).toEqual({ index: 0, key: 'modelIdRequired' })
expect(validateDeepSeekModels([{ id: 'model' }, { id: 'model ' }]))
.toEqual({ index: 1, key: 'modelIdDuplicate' })
})
it('renders malformed draft fallbacks without inventing catalog values', () => {
render(<DeepSeekModelsEditor
models={[{}]}
overridden={false}
defaultContextWindow={undefined}
defaultMaxTokens={undefined}
t={t}
disabled={true}
onChange={vi.fn()}
onReset={vi.fn()}
/>)
expect(screen.getByLabelText<HTMLInputElement>(`${en.modelId} 1`).value).toBe('')
expandRow(1)
expect(screen.getByLabelText<HTMLInputElement>(`${en.contextWindow} 1`).placeholder)
.toBe(en.contextWindowPlaceholder)
expect(screen.getByLabelText<HTMLInputElement>(`${en.maxTokens} 1`).placeholder)
.toBe(en.maxTokensPlaceholder)
})
it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => {
const { mutate } = await mountSection({
mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
})
fireEvent.click(screen.getByText(en.customized))
fireEvent.click(screen.getAllByLabelText(new RegExp(en.removeModel))[0] as HTMLElement)
fireEvent.click(screen.getByLabelText(new RegExp(en.removeModel)))
expect(screen.getByText(en.modelsEmpty)).toBeTruthy()
fireEvent.click(screen.getByText(en.resetModels))
expect(screen.getByText(en.modelsInherited)).toBeTruthy()
const names = screen.getAllByLabelText(new RegExp(en.modelName))
expandRow(1)
const windows = capacityInputs(en.contextWindow)
fireEvent.change(names[0] as HTMLInputElement, { target: { value: '' } })
fireEvent.change(windows[0] as HTMLInputElement, { target: { value: '' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-deepseek',
ops: [{
op: 'set',
path: ['models'],
value: [
{ id: 'deepseek-v4-flash', description: 'Preserved hidden detail' },
DEFAULT_DEEPSEEK_MODELS[1],
],
}],
expectedRevision: 0,
})
})
it('clears an inherited override with an unset op, never a whole-section replace', async () => {
// The data-loss shape: the old path rebuilt the section from the REDACTED
// user layer and replaced it wholesale, deleting any stored literal key.

View File

@@ -3,11 +3,36 @@ 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')
const tokens = readFileSync(
fileURLToPath(new URL('../../ui-theme/src/styles/design-platform.css', import.meta.url)),
'utf8',
)
/** The declarations of one top-level rule, by selector. */
function block(selector: string): string {
const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`)
return match[1] ?? ''
}
describe('ModelsSection theme styles', () => {
it('uses the shared theme tokens without light-only fallbacks', () => {
it('names only theme variables the token sheet defines', () => {
// A `--dsw-*` name the sheet never declares is not a near miss: it silently
// resolves to whatever literal sits in its fallback slot, which is how this
// section stayed light under the dark theme before. Undeclared names have
// no fallback at all and inherit, so both spellings must fail here.
const named = [...css.matchAll(/var\((--dsw-[a-z0-9-]+)/g)].map(match => match[1])
const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
expect(undeclared).toEqual([])
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)')
})
it('separates the row card from the editor it expands into', () => {
// `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
// under the dark theme, so filling the row with either erases the nested
// editor's boundary. The row is outlined; the fill is the editor's alone.
expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)')
expect(block('.rowCard')).toContain('border: 1px solid var(--dsw-alias-border-l2)')
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
})
})