diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 32137fe409..79e779ffdf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: a4d474d450009b3bcf929eaea870e045602268ac -2026-07-30-web-config-plane.zh.md: db7201408f46a1e68b4359346d1c74b8d728a0d3 +2026-07-30-web-config-plane.md: 5970cfcea2e577998a235d08769ba497f5e8c18b +2026-07-30-web-config-plane.zh.md: 7a9fc6a7a0484ec7ae5d7691ffe26eddfd33fb54 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index a4d474d450..5970cfcea2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -18,7 +18,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. -**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. The context window is a text field reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: the field holds the typed text while the row has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. +**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. **The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index db7201408f..7a9fc6a7a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -18,7 +18,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 -**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。上下文窗口是一个文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:该行持有焦点期间,字段保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。 +**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 **Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 984d19db05..51fa84af3d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -173,12 +173,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() await settings.getByText('自定义设置').click() - await settings.getByRole('button', { name: '删除模型' }).first().click() + await settings.getByRole('button', { name: /删除模型/ }).first().click() await settings.getByRole('button', { name: '添加模型' }).click() const customModelId = settings.getByLabel('模型 ID 2') await customModelId.fill('private-preview') await settings.getByLabel('显示名称 2').fill('Private Preview') + // Capacities live behind the row's own disclosure, as in the pi-ai form. + await settings.getByRole('button', { name: '容量 2' }).click() await settings.getByLabel('上下文窗口 2').fill('131072') + await settings.getByLabel('最大输出 token 数 2').fill('64K') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) @@ -190,6 +193,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('id: private-preview') expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') + expect(document).toContain('maxTokens: 64000') expect(document).not.toContain('id: deepseek-v4-flash') await page.keyboard.press('Escape') diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 4a709fdf5a..c71c1981ac 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -32,26 +32,34 @@ - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" - - textbox "模型 ID 1": deepseek-v4-pro + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: deepseek-v4-pro - textbox "显示名称 1": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - textbox "上下文窗口 1": - - /placeholder: 1M - - text: 1M - - button "删除模型": + - button "容量 1": - img - - text: 删除模型 - - textbox "模型 ID 2": private-preview + - button "删除模型 1": + - img + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: private-preview - textbox "显示名称 2": - - /placeholder: 留空时使用模型 ID + - /placeholder: 显示名称 - text: Private Preview + - button "容量 2" [expanded]: + - img + - button "删除模型 2": + - img + - text: 上下文窗口 - textbox "上下文窗口 2": - /placeholder: 1M - text: "131072" - - button "删除模型": - - img - - text: 删除模型 + - text: 最大输出 token 数 + - textbox "最大输出 token 数 2": + - /placeholder: 256K + - text: 64K - button "添加模型": - img - text: 添加模型 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 65843fbadb..38b961df53 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 6ce66b5e0614fc2b6820c5d95f294fcc1a30d4de -README.zh.md: 0a28aa627d4d0c8dd54cffe690d334d4e10fa69d +README.md: d403b15247173e6290214eeae7a401851e835700 +README.zh.md: adb37bb25420bf4e038153ba8a5c213b8a993791 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ce66b5e06..d403b15247 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp 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 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 context window 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 context windows 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. +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 diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 0a28aa627d..adb37bb254 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。上下文窗口按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的上下文窗口都会在写入前失败。每次写入都携带该卡片打开时的 `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 新生的路由都无需轮询即可收敛。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx index 13aab18dab..57221cc93a 100644 --- a/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx +++ b/packages/client/ui-models/src/client/DeepSeekModelsEditor.tsx @@ -7,33 +7,46 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import { IconPlusOutline16, IconTrashOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +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 -/** Accepted context-window spellings: a decimal count with an optional K/M suffix. */ -const CONTEXT_WINDOW_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i +/** 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 CONTEXT_WINDOW_SCALE = { k: 1_000, m: 1_000_000 } as const +const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const /** - * Read a typed context window, so a user can write `256K` or `1M` instead of - * counting zeroes. The stored value stays a plain token count. + * 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 parseContextWindow(text: string): number | undefined { +export function parseCapacity(text: string): number | undefined { const trimmed = text.trim() if (trimmed.length === 0) return undefined - const match = CONTEXT_WINDOW_PATTERN.exec(trimmed) + const match = CAPACITY_PATTERN.exec(trimmed) if (match === null) return Number.NaN const suffix = match[2]?.toLowerCase() - const scale = suffix === 'k' || suffix === 'm' ? CONTEXT_WINDOW_SCALE[suffix] : 1 + 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. @@ -43,15 +56,15 @@ export function parseContextWindow(text: string): number | undefined { /** * Spell a stored count back in the shortest form that survives a round trip - * through {@link parseContextWindow}; a count that is not a whole number of + * through {@link parseCapacity}; a count that is not a whole number of * thousands stays written out. - * @param value - stored context window. + * @param value - stored capacity. * @returns the field text. */ -export function formatContextWindow(value: number): string { +export function formatCapacity(value: number): string { if (!Number.isInteger(value) || value <= 0) return String(value) - if (value % CONTEXT_WINDOW_SCALE.m === 0) return `${String(value / CONTEXT_WINDOW_SCALE.m)}M` - if (value % CONTEXT_WINDOW_SCALE.k === 0) return `${String(value / CONTEXT_WINDOW_SCALE.k)}K` + 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) } @@ -61,6 +74,7 @@ export interface DeepSeekModelsValidationFailure { 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. */ @@ -99,6 +113,11 @@ export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidation && (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 } @@ -109,8 +128,10 @@ export interface DeepSeekModelsEditorProps { models: readonly DeepSeekModelDraft[] /** Whether the user layer currently owns the whole array. */ overridden: boolean - /** Fallback capacity used when a row omits its exact value. */ + /** 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. */ @@ -122,25 +143,27 @@ export interface DeepSeekModelsEditorProps { } /** - * Render the direct DeepSeek adapter's id/name/context-window catalog. + * 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 { - // Context windows are edited as text, so a row's keystrokes are held here + // 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 ROW: a single active buffer would be displaced by - // editing any other row, and the abandoned row would fall back to rendering - // its stored NaN as the literal `NaN`. + // 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`. // - // Entries are keyed by 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>(() => new Map()) + // 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>(() => new Map()) + const [expanded, setExpanded] = useState>(() => new Set()) - const update = (index: number, key: 'id' | 'name' | 'contextWindow', value: unknown): void => { + const update = (index: number, key: CatalogField, value: unknown): void => { const next = props.models.map((model, at) => { const copy = { ...model } if (at !== index) return copy @@ -153,10 +176,20 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const remove = (index: number): void => { setEditing((current) => { - const next = new Map() - for (const [at, text] of current) { + const next = new Map() + for (const [key, text] of current) { + const at = rowOf(key) if (at === index) continue - next.set(at > index ? at - 1 : at, text) + // 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() + for (const at of current) { + if (at === index) continue + next.add(at > index ? at - 1 : at) } return next }) @@ -165,34 +198,73 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod const reset = (): void => { setEditing(new Map()) + setExpanded(new Set()) props.onReset() } - /** The row's field text: its live keystrokes, else the stored count spelled short. */ - const contextText = (model: DeepSeekModelDraft, index: number): string => { - const typed = editing.get(index) - if (typed !== undefined) return typed - const value = model['contextWindow'] - return typeof value === 'number' ? formatContextWindow(value) : '' - } - - const settleContext = (index: number): void => { - const typed = editing.get(index) - 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 = parseContextWindow(typed) - if (parsed !== undefined && Number.isNaN(parsed)) return - setEditing((current) => { - const next = new Map(current) - next.delete(index) + 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 => ( + + ) + return (
-
+
{props.t('models')} @@ -215,66 +287,65 @@ export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNod {props.models.length === 0 ?

{props.t('modelsEmpty')}

: ( -
- {/* Captions sit above the rows and are hidden from assistive tech: - every field already carries the indexed `aria-label` naming it. */} - +
{props.models.map((model, index) => ( -
- { 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) - }} - /> - { - update(index, 'name', event.target.value === '' ? undefined : event.target.value) - }} - /> - { - const text = event.target.value - setEditing(current => new Map(current).set(index, text)) - update(index, 'contextWindow', parseContextWindow(text)) - }} - onBlur={() => { settleContext(index) }} - /> - +
+
+ { 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) + }} + /> + { + update(index, 'name', event.target.value === '' ? undefined : event.target.value) + }} + /> + + +
+ {expanded.has(index) + ? ( +
+ {capacityField(model, index, 'contextWindow', props.defaultContextWindow)} + {capacityField(model, index, 'maxTokens', props.defaultMaxTokens)} +
+ ) + : null}
))}
diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index eb000d4d99..2e7ea02326 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -156,8 +156,7 @@ .dangerButton:disabled, .addButton:disabled, .linkButton:disabled, -.addModelButton:disabled, -.rowDelete:disabled { +.addModelButton:disabled { opacity: 0.4; cursor: default; } @@ -168,7 +167,7 @@ .addButton:focus-visible, .linkButton:focus-visible, .addModelButton:focus-visible, -.rowDelete:focus-visible, +.iconButton:focus-visible, .customizedSummary:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); @@ -347,13 +346,6 @@ border-top: 1px solid var(--dsw-alias-border-l2); } -.modelCatalogHeader { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - .modelCatalogHeading { display: flex; flex-direction: column; @@ -375,54 +367,89 @@ line-height: 18px; } -.modelTable { +/* 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: 6px; -} - -/* Captions and rows share one track list so the columns line up. */ -.modelColumns, -.modelRow { - display: grid; - grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.25fr) minmax(88px, 0.75fr) 28px; - align-items: center; gap: 8px; } -.modelColumns { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; +.modelListHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; } -/* The inset belongs on the caption cell, not the strip: padding on the grid - container would narrow its tracks against the rows' and walk the captions - left column by column. 1px border + 10px padding is the field text inset. */ -.modelColumns > span { - padding-left: 11px; +.modelEntry { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 6px; } -.rowDelete { - box-sizing: border-box; - position: relative; +.modelRow { display: grid; - place-items: center; + 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; - padding: 0; border: none; - border-radius: 8px; + border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; } -.rowDelete:hover:not(:disabled) { +.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); diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index a27fa14002..6876e58356 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -262,6 +262,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = getPath(fallback, ['maxTokens']) return ( <>
@@ -323,6 +324,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultContextWindow={typeof defaultContextWindow === 'number' ? defaultContextWindow : undefined} + defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} t={t} disabled={disabled} onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index f64700b11f..bb1254e46b 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -40,6 +40,9 @@ export const en = { 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.', @@ -47,6 +50,7 @@ export const en = { 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.', @@ -97,6 +101,9 @@ export const zh: typeof en = { modelNamePlaceholder: '留空时使用模型 ID', contextWindow: '上下文窗口', contextWindowPlaceholder: '使用提供方默认值', + maxTokens: '最大输出 token 数', + maxTokensPlaceholder: '使用提供方默认值', + modelAdvanced: '容量', addModel: '添加模型', removeModel: '删除模型', modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。', @@ -104,6 +111,7 @@ export const zh: typeof en = { modelIdDuplicate: '模型 ID 不能重复。', modelNameInvalid: '显示名称不能为空。', modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。', + modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。', advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。', onboardingTitle: '添加一个 API Key 开始使用', onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 7346332a2f..6c053bba59 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -9,7 +9,7 @@ import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { - DeepSeekModelsEditor, formatContextWindow, modelDrafts, parseContextWindow, validateDeepSeekModels, + 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' @@ -19,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(new RegExp(label)) +} + const PiAiConfig = Schema.object({ token: Schema.string().role('secret'), providers: Schema.dict(Schema.object({ @@ -79,9 +89,10 @@ function wireNamespaces(): SettingsNamespaceView[] { baseURL: 'https://base', reasoningEffort: 'high', defaultContextWindow: 1_000_000, + maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS, }, - base: { defaultContextWindow: 1_000_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 }], @@ -297,10 +308,11 @@ describe('ModelsSection', () => { fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) const names = screen.getAllByLabelText(new RegExp(en.modelName)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(3) fireEvent.change(ids[2] as HTMLInputElement, { target: { value: 'private-preview' } }) fireEvent.change(names[2] as HTMLInputElement, { target: { value: 'Private Preview' } }) - fireEvent.change(windows[2] as HTMLInputElement, { target: { value: '131072' } }) + // 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) }) @@ -345,39 +357,46 @@ describe('ModelsSection', () => { 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(parseContextWindow('')).toBeUndefined() - expect(parseContextWindow(' ')).toBeUndefined() - expect(parseContextWindow('131072')).toBe(131_072) - expect(parseContextWindow(' 256K ')).toBe(256_000) - expect(parseContextWindow('256k')).toBe(256_000) - expect(parseContextWindow('1M')).toBe(1_000_000) - expect(parseContextWindow('1m')).toBe(1_000_000) + 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(parseContextWindow('1M')).toBe(parseContextWindow('1000K')) + 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(parseContextWindow('2.3M')).toBe(2_300_000) - expect(Number.isInteger(parseContextWindow('1.5M'))).toBe(true) + 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(parseContextWindow('0.0001K')).toBeCloseTo(0.1) - expect(parseContextWindow('abc')).toBeNaN() - expect(parseContextWindow('1G')).toBeNaN() - expect(parseContextWindow('1M1')).toBeNaN() + 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(formatContextWindow(1_000_000)).toBe('1M') - expect(formatContextWindow(256_000)).toBe('256K') - expect(formatContextWindow(1_500_000)).toBe('1500K') - expect(formatContextWindow(131_072)).toBe('131072') + 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(formatContextWindow(Number.NaN)).toBe('NaN') - expect(formatContextWindow(0)).toBe('0') + expect(formatCapacity(Number.NaN)).toBe('NaN') + expect(formatCapacity(0)).toBe('0') for (const text of ['1M', '256K', '131072', '1500K']) { - expect(formatContextWindow(parseContextWindow(text) as number)).toBe(text) + expect(formatCapacity(parseCapacity(text) as number)).toBe(text) } }) @@ -386,7 +405,9 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + expandRow(1) + expandRow(2) + const windows = capacityInputs(en.contextWindow) // The inherited 1000000 reads back short. expect((windows[0] as HTMLInputElement).value).toBe('1M') @@ -422,7 +443,9 @@ describe('ModelsSection', () => { it('keeps unreadable context-window text on screen and refuses the write', async () => { const { mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + 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) @@ -483,7 +506,9 @@ describe('ModelsSection', () => { // losing the text the user was told they could still correct. await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + 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' } }) @@ -495,13 +520,15 @@ describe('ModelsSection', () => { it('re-keys the typed text around a removed row', async () => { await mountSection() fireEvent.click(screen.getByText(en.customized)) - const windows = (): HTMLInputElement[] => - screen.getAllByLabelText(new RegExp(en.contextWindow)) + const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { - fireEvent.click(screen.getAllByText(en.removeModel)[at] as HTMLElement) + 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' } }) @@ -529,12 +556,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + 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)) - const restored = screen.getAllByLabelText(new RegExp(en.contextWindow)) + // 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 @@ -544,6 +574,40 @@ describe('ModelsSection', () => { 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(`${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(`${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)) @@ -567,14 +631,18 @@ describe('ModelsSection', () => { models={[{}]} overridden={false} defaultContextWindow={undefined} + defaultMaxTokens={undefined} t={t} disabled={true} onChange={vi.fn()} onReset={vi.fn()} />) expect(screen.getByLabelText(`${en.modelId} 1`).value).toBe('') + expandRow(1) expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder) .toBe(en.contextWindowPlaceholder) + expect(screen.getByLabelText(`${en.maxTokens} 1`).placeholder) + .toBe(en.maxTokensPlaceholder) }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { @@ -582,14 +650,15 @@ describe('ModelsSection', () => { mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) - fireEvent.click(screen.getAllByText(en.removeModel)[0] as HTMLElement) - fireEvent.click(screen.getByText(en.removeModel)) + 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)) - const windows = screen.getAllByLabelText(new RegExp(en.contextWindow)) + 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)) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts index 9139d01f4e..879d9812a2 100644 --- a/packages/client/ui-models/tests/styles.spec.ts +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -3,6 +3,10 @@ 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 { @@ -12,12 +16,15 @@ function block(selector: string): string { } describe('ModelsSection theme styles', () => { - it('uses the shared theme tokens without light-only fallbacks', () => { - // The section once named `--border`/`--surface`/`--text-*`/`--accent-strong`, - // which nothing in this app defines, so it rendered the light-mode literals - // written as their fallbacks and stayed light under the dark theme. + 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('color: var(--dsw-alias-label-primary)') }) it('separates the row card from the editor it expands into', () => {