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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 注入面类型。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
364
packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx
Normal file
364
packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 官方模型,即可开始使用。',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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*:/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user