feat(ui-models)!: single-key hand-written provider editors with derived credential references
The Models page drops the generic schema renderer and the visible environment-variable field: each editor is a curated per-family card whose primary input is one write-only API key stored under a derived <ROUTE>_API_KEY reference (recorded as apiKeyEnv in the pi-ai profile), an unkeyed whole-section provider opens as its setup card, and the collapsed customized-settings fold carries baseURL/reasoningEffort (deepseek) or reasoning (pi-ai). dsh-client-schema-form reduces to the schema/draft model layer (no React).
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
// Web e2e scenario: the Models settings page end to end through the real
|
||||
// wire — the dormant pi-ai directory renders as the add vocabulary, adding a
|
||||
// provider writes the settings document and registers the route live (the
|
||||
// row's 已启用 badge is the topology invalidation landing), and the key input
|
||||
// stores a credential write-only into the harness home's .env. Zero model
|
||||
// calls: configuration is pure settings/credentials/llm-domain traffic, so
|
||||
// there is no fixture and a stray stream would fail loud on the open seam.
|
||||
// wire — the add card offers the dormant pi-ai catalog, typing an API key
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference, and the saved
|
||||
// route registers live (the row's 已启用 badge is the topology invalidation
|
||||
// landing). The customized-settings fold writes the curated reasoning field
|
||||
// as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -42,7 +46,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders the dormant directory as the add vocabulary', async () => {
|
||||
it('opens the add card over the dormant directory vocabulary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -50,60 +54,59 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
||||
// The dormant pi-ai adapter contributes its whole installed catalog; no
|
||||
// provider is configured yet, so the page is one add-select.
|
||||
const add = dialog.getByLabel('添加提供方')
|
||||
// provider is configured yet, so the page is one add button.
|
||||
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
|
||||
await add.waitFor({ timeout: 10_000 })
|
||||
// The select renders before the directory join settles; poll until the
|
||||
// dormant catalog landed.
|
||||
await expect.poll(async () => add.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
|
||||
const options = await add.locator('option').allTextContents()
|
||||
// The button enables once the dormant catalog lands in the join.
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
const pick = dialog.getByLabel('提供方')
|
||||
await pick.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
|
||||
const options = await pick.locator('option').allTextContents()
|
||||
expect(options).toContain('anthropic')
|
||||
expect(options).toContain('openai')
|
||||
expect(options).toContain('minimax-cn')
|
||||
await pick.selectOption('minimax-cn')
|
||||
await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('adds a provider through the schema-driven editor and the route registers live', async () => {
|
||||
it('stores the key under the derived reference and the route registers live', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByLabel('添加提供方').selectOption('anthropic')
|
||||
// The editor is the real pi-ai profile schema rendered field by field;
|
||||
// the credential-reference control is the role-tagged override.
|
||||
const ref = dialog.getByLabel('API 密钥环境变量')
|
||||
await ref.waitFor({ timeout: 10_000 })
|
||||
// A test-owned reference name keeps this hermetic: a developer's real
|
||||
// ANTHROPIC_API_KEY in the process environment must not flip the badge.
|
||||
await ref.fill('E2E_ANTHROPIC_KEY')
|
||||
await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The write lands in settings.yaml, the dormant route registers, the
|
||||
// topology frame invalidates the page, and the reloaded join shows the
|
||||
// row live with its credential still missing.
|
||||
const row = dialog.getByText('anthropic', { exact: true }).first()
|
||||
// The profile lands in settings.yaml with only the derived reference, the
|
||||
// key value lands in the harness home's .env, the dormant route
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
const row = dialog.getByText('minimax-cn', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('缺少密钥').waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('llm-pi-ai:')
|
||||
expect(document).toContain('anthropic:')
|
||||
expect(document).toContain('apiKeyEnv: E2E_ANTHROPIC_KEY')
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
expect(document).not.toContain('sk-e2e-minimax')
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
||||
}, 60_000)
|
||||
|
||||
it('stores the API key write-only and the badge flips configured', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-key'))
|
||||
it('applies a customized-settings field as a merge patch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑' }).click()
|
||||
const key = dialog.getByLabel('API 密钥', { exact: true })
|
||||
await key.waitFor({ timeout: 10_000 })
|
||||
await key.fill('sk-ant-e2e-test')
|
||||
await dialog.getByRole('button', { name: '保存密钥' }).click()
|
||||
await dialog.getByText('已配置', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
// The value went to the harness home's .env — and nowhere in the DOM.
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored).toContain('E2E_ANTHROPIC_KEY=sk-ant-e2e-test')
|
||||
expect(await page.content()).not.toContain('sk-ant-e2e-test')
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
// The row badge converges from the credentials invalidation.
|
||||
await expect.poll(async () => dialog.getByText('缺少密钥').count(), { timeout: 10_000 }).toBe(0)
|
||||
await dialog.getByText('自定义设置').click()
|
||||
const effort = dialog.getByLabel('推理强度')
|
||||
await effort.waitFor({ timeout: 10_000 })
|
||||
await effort.selectOption('high')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The editor closes back to the row; the fold's write merged into the
|
||||
// stored profile beside the reference.
|
||||
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('reasoning: high')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
@@ -14,44 +14,7 @@
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: anthropic 已启用
|
||||
- text: minimax-cn 已启用
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- combobox "添加提供方":
|
||||
- option "+ 添加提供方" [selected]
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "azure-openai-responses"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn"
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- button "+ 添加提供方"
|
||||
@@ -13,8 +13,8 @@
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list
|
||||
- combobox "添加提供方":
|
||||
- option "+ 添加提供方" [selected]
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
@@ -31,7 +31,7 @@
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
@@ -52,3 +52,9 @@
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -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/schema-form/README.md
|
||||
README.md: d6819ccf29cf58667d518c43c43b875eb20e02e9
|
||||
README.zh.md: 2f2e07d41db2af5f0afc3352072e7d49129ce6f2
|
||||
README.md: 23e69f80914b400a77c036192f564d32bc148310
|
||||
README.zh.md: b26593d971d0c53d1fd8d0778200914a90b9b891
|
||||
@@ -2,21 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Schema-driven React form renderer for settings sections. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `SchemaForm` rehydrates it with `new Schema(json)` and renders every declared field as an editable control — the same schema object that validates a section on the host validates and drives the form in the browser, so there is no second form definition to drift.
|
||||
Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the seam's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
|
||||
|
||||
## Contract
|
||||
|
||||
`SchemaForm` is a controlled component over a **draft user section**: `draft` is the object being edited (never mutated; every edit calls `onChange` with a new root), and `fallback` is the resolved value (schema defaults → composition base → user layer) used for inherited display. A field's presence in the draft marks it **overridden** and shows a per-field Reset that deletes the key, falling back to the inherited layer — presence semantics, not value comparison, exactly mirroring the settings seam's layering.
|
||||
|
||||
Controls by schema node: `object` → labeled field groups (JSDoc `description` rendered, `required` starred), `string`/`number`/`boolean` → inputs with the inherited value as placeholder, `union` of literals → select whose empty option means "inherit", `array` → positional rows with add/remove (arrays replace wholesale on write), `dict` → keyed rows where a union-typed `sKey` becomes the add-select's vocabulary. `role('secret')` renders a **write-only** password input: the stored value never arrives (the wire strips it), and the `secrets` slot list (`{path, set}`) supplies the placeholder state. A node the renderer cannot faithfully edit (non-literal unions, transforms) renders a read-only JSON view with a notice instead of disappearing — a schema field is never silently dropped.
|
||||
|
||||
`renderField(context)` is the role-aware override hook: return a node to replace the default control for one leaf. The Models settings page uses it to mount the credential-reference control (`role('credential-ref')`) that talks to `credentials.*` — this package stays wire-free and side-effect-free.
|
||||
|
||||
`validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages validate before writing; the path helpers (`getPath`/`hasPath`/`setPath`/`deletePath`) expose the same immutable draft editing the controls use.
|
||||
The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders browser configuration forms; nothing here reaches a model request.
|
||||
None, as this package backs browser configuration editors; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -24,7 +18,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Validation is form-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); inline per-field error placement is deferred until a second consumer needs it.
|
||||
- **Strings are built-in English** — the `labels` prop overrides every user-visible string, but there is no locale-dictionary wiring inside this package; the embedding page owns localization.
|
||||
- **Non-literal unions and transforms render read-only** — faithful editing of those shapes needs per-shape controls; today they fall back to the JSON view with a notice.
|
||||
- **Array editing replaces wholesale** — element-level merge does not exist at the settings seam either; the form mirrors that contract rather than hiding it.
|
||||
- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
|
||||
- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.
|
||||
@@ -2,21 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 settings 分节的 schema 驱动 React 表单渲染器。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`SchemaForm` 用 `new Schema(json)` 将其还原(rehydrate),并把每个已声明的字段渲染为可编辑控件——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验并驱动表单的那份对象,因此不存在第二份会漂移的表单定义。
|
||||
面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包(package)不含任何 React,也不做任何渲染。
|
||||
|
||||
## 契约
|
||||
|
||||
`SchemaForm` 是围绕**用户分节草稿**的受控组件:`draft` 是正在编辑的对象(绝不被原地修改;每次编辑都以新的根对象调用 `onChange`),`fallback` 则是用于展示继承值的解析值(schema 默认值 → 组合 base → 用户层)。字段只要出现在草稿中就被标记为**已覆盖**,并显示一个删除该键、回退到继承层的逐字段 Reset——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。
|
||||
|
||||
控件按 schema 节点分派:`object` → 带标签的字段组(渲染 JSDoc `description`,`required` 字段加星标);`string`/`number`/`boolean` → 以继承值为占位符的输入框;字面量 `union` → 下拉框,空选项表示「继承」;`array` → 按位置排列、可增删的行(数组在写入时整体替换);`dict` → 按键排列的行,其中联合类型的 `sKey` 成为「新增」下拉框的词汇。`role('secret')` 渲染为**只写**的密码输入框:已存储的值永远不会送达(wire 会剥除它),占位状态由 `secrets` 槽位列表(`{path, set}`)提供。渲染器无法忠实编辑的节点(非字面量联合、转换(transform)节点)渲染为带提示的只读 JSON 视图,而不是直接消失——schema 字段绝不会被静默丢弃。
|
||||
|
||||
`renderField(context)` 是感知角色的覆盖钩子:返回一个节点,即可替换单个叶子字段的默认控件。Models 设置页用它挂载与 `credentials.*` 通信的凭据引用控件(`role('credential-ref')`)——该包(package)自身始终不接触 wire,也没有副作用。
|
||||
|
||||
`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以先校验再写入;路径辅助函数(`getPath`/`hasPath`/`setPath`/`deletePath`)对外暴露的不可变草稿编辑,与控件内部使用的是同一套。
|
||||
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无。该包渲染的是浏览器配置表单;这里没有任何内容进入模型请求。
|
||||
无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -24,7 +18,5 @@
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **校验是表单级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的内联报错展示延后到出现需要它的第二个消费方再做。
|
||||
- **字符串内置为英文**——`labels` prop 可以覆盖每一条用户可见字符串,但包内没有接入语言环境词典的接线;本地化归嵌入它的页面所有。
|
||||
- **非字面量联合与转换节点只读渲染**——忠实编辑这些形状需要逐形状的控件;目前它们回退为带提示的 JSON 视图。
|
||||
- **数组编辑整体替换**——settings seam 同样不存在元素级合并;表单如实呈现该契约,而不是把它藏起来。
|
||||
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
|
||||
- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-schema-form",
|
||||
"description": "Schema-driven React form renderer: rehydrates a serialized schemastery schema and renders/edits a settings draft against it",
|
||||
"description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -20,7 +20,6 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -29,7 +28,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field.group {
|
||||
border: 1px solid var(--border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.control:focus {
|
||||
outline: 2px solid var(--accent, #3964fe);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #3964fe);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dictKey {
|
||||
min-width: 96px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unsupported {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.unsupported pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken, #f5f5f5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
Binary file not shown.
@@ -1,6 +0,0 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* Schema-driven React form renderer for settings sections. `SchemaForm`
|
||||
* rehydrates the wire's serialized schemastery envelope and edits a draft
|
||||
* user section against it; the model helpers expose the same introspection
|
||||
* and immutable path editing for page-level composition.
|
||||
* Schema/draft model layer for settings editors: rehydrate the wire's
|
||||
* serialized schemastery envelope, resolve nodes by settings path, validate
|
||||
* drafts, and edit them immutably by path. Editors render their own controls
|
||||
* (the Models page hand-writes its layout) on top of these helpers.
|
||||
* @module @deepseek-ai/dsh-client-schema-form
|
||||
*/
|
||||
|
||||
export { SchemaForm } from './SchemaForm.tsx'
|
||||
export type {
|
||||
SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
|
||||
} from './SchemaForm.tsx'
|
||||
export {
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from './model.ts'
|
||||
export type { NodeKind, SchemaNode } from './model.ts'
|
||||
export type { SchemaNode } from './model.ts'
|
||||
@@ -15,10 +15,10 @@ export const name = 'client-schema-form-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure React rendering library — it emits no cordis
|
||||
* events and owns no cross-plugin mutable relation; draft immutability,
|
||||
* schema rehydration, and control/edit round trips are asserted directly by
|
||||
* this package's component and model specs.
|
||||
* No runtime invariant: a pure schema/draft helper library — it emits no
|
||||
* cordis events and owns no cross-plugin mutable relation; draft
|
||||
* immutability, schema rehydration, and path-edit round trips are asserted
|
||||
* directly by this package's model specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Schema introspection and draft-editing helpers behind the form renderer.
|
||||
* Schema introspection and draft-editing helpers behind settings editors.
|
||||
* The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
|
||||
* live validator whose node relations (`dict`/`inner`/`list`) the renderer
|
||||
* walks; drafts are edited immutably by path.
|
||||
* live validator whose node relations (`dict`/`inner`) editors probe for
|
||||
* field presence and roles; drafts are edited immutably by path.
|
||||
* @module @deepseek-ai/dsh-client-schema-form/model
|
||||
*/
|
||||
|
||||
@@ -35,49 +35,6 @@ export function validateDraft(schema: SchemaNode, draft: unknown): string | unde
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderable classification of one schema node. */
|
||||
export type NodeKind =
|
||||
| 'object'
|
||||
| 'dict'
|
||||
| 'array'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'union-const'
|
||||
| 'unsupported'
|
||||
|
||||
/**
|
||||
* Classify one node into the renderer's vocabulary. A union renders as a
|
||||
* select only when every branch is a literal; everything else the renderer
|
||||
* cannot faithfully edit is `unsupported` and falls back to a read-only view
|
||||
* (never silently dropped).
|
||||
* @param node - live schema node.
|
||||
* @returns the control family for this node.
|
||||
*/
|
||||
export function nodeKind(node: SchemaNode): NodeKind {
|
||||
switch (node.type) {
|
||||
case 'object': return 'object'
|
||||
case 'dict': return 'dict'
|
||||
case 'array': return 'array'
|
||||
case 'string': return 'string'
|
||||
case 'number': return 'number'
|
||||
case 'boolean': return 'boolean'
|
||||
case 'union':
|
||||
return (node.list ?? []).every(branch => branch.type === 'const') ? 'union-const' : 'unsupported'
|
||||
default:
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal choices of a `union-const` node, in declaration order.
|
||||
* @param node - a node classified `union-const`.
|
||||
* @returns each branch's literal value.
|
||||
*/
|
||||
export function unionChoices(node: SchemaNode): unknown[] {
|
||||
return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the schema node at a settings path (the configurable-provider
|
||||
* directory's `settingsPath` vocabulary): object properties by name, dict
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
@@ -21,33 +21,6 @@ describe('rehydration and validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeKind', () => {
|
||||
it.each([
|
||||
[Schema.object({}), 'object'],
|
||||
[Schema.dict(Schema.string()), 'dict'],
|
||||
[Schema.array(Schema.string()), 'array'],
|
||||
[Schema.string(), 'string'],
|
||||
[Schema.number(), 'number'],
|
||||
[Schema.natural(), 'number'],
|
||||
[Schema.boolean(), 'boolean'],
|
||||
[Schema.union(['a', 'b']), 'union-const'],
|
||||
[Schema.union([Schema.string(), Schema.number()]), 'unsupported'],
|
||||
[Schema.transform(Schema.string(), value => value), 'unsupported'],
|
||||
])('classifies %#', (schema, expected) => {
|
||||
expect(nodeKind(rehydrateSchema(Wire(schema as Schema)))).toBe(expected)
|
||||
})
|
||||
|
||||
it('lists union choices in declaration order', () => {
|
||||
const node = rehydrateSchema(Wire(Schema.union(['off', 'high', 'max'])))
|
||||
expect(unionChoices(node)).toEqual(['off', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('tolerates structural union nodes missing their branch list', () => {
|
||||
expect(nodeKind({ type: 'union', meta: {} } as never)).toBe('union-const')
|
||||
expect(unionChoices({ type: 'union', meta: {} } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('path helpers', () => {
|
||||
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { SchemaForm } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
|
||||
const Profile = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string().description('Endpoint override'),
|
||||
reasoning: Schema.union(['off', 'high', 'max']),
|
||||
timeoutMs: Schema.number().min(0).max(1000).step(1),
|
||||
verbose: Schema.boolean(),
|
||||
name: Schema.string().required(),
|
||||
})
|
||||
|
||||
function lastDraft(onChange: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
||||
return onChange.mock.calls.at(-1)?.[0] as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('leaf controls', () => {
|
||||
it('renders strings with inherited placeholders, writes on input, clears on empty', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ baseURL: 'https://mine' }}
|
||||
fallback={{ baseURL: 'https://base', reasoning: 'high' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = screen.getByDisplayValue('https://mine')
|
||||
fireEvent.change(input, { target: { value: 'https://next' } })
|
||||
expect(lastDraft(onChange)).toEqual({ baseURL: 'https://next' })
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
const inherited = screen.getByPlaceholderText('Default: https://base')
|
||||
expect(inherited).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders numbers with bounds and parses edits', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ timeoutMs: 500 }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="number"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Default: 500')
|
||||
expect(input.min).toBe('0')
|
||||
expect(input.max).toBe('1000')
|
||||
fireEvent.change(input, { target: { value: '250' } })
|
||||
expect(lastDraft(onChange)).toEqual({ timeoutMs: 250 })
|
||||
})
|
||||
|
||||
it('clears a number override back to inherited on empty input', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ timeoutMs: 250 }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="number"]') as HTMLInputElement
|
||||
expect(input.value).toBe('250')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('prefers an overridden boolean over the fallback', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ verbose: false }}
|
||||
fallback={{ verbose: true }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||
expect(box.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects booleans from the fallback until overridden', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ verbose: true }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||
expect(box.checked).toBe(true)
|
||||
fireEvent.click(box)
|
||||
expect(lastDraft(onChange)).toEqual({ verbose: false })
|
||||
})
|
||||
|
||||
it('renders literal unions as selects with an inherit option', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
fallback={{ reasoning: 'high' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
expect([...select.options].map(option => option.text)).toEqual(['Default: high', 'off', 'high', 'max'])
|
||||
fireEvent.change(select, { target: { value: 'max' } })
|
||||
expect(lastDraft(onChange)).toEqual({ reasoning: 'max' })
|
||||
})
|
||||
|
||||
it('clears a union override back to inherit', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ reasoning: 'max' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
expect(select.value).toBe('max')
|
||||
fireEvent.change(select, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('marks required fields and surfaces descriptions', () => {
|
||||
render(<SchemaForm schema={Wire(Profile)} draft={{}} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('Endpoint override')).toBeTruthy()
|
||||
expect(screen.getByText('name').textContent).toContain('name')
|
||||
expect(screen.getByText('*')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the per-field reset only for overridden fields and deletes on click', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ baseURL: 'https://mine' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const resets = screen.getAllByText('Reset')
|
||||
expect(resets).toHaveLength(1)
|
||||
fireEvent.click(resets[0] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('secrets and custom renderers', () => {
|
||||
it('renders secrets write-only with the stored-state placeholder', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
secrets={[{ path: ['apiKey'], set: true }]}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Configured — enter a new value to replace')
|
||||
expect(input.value).toBe('')
|
||||
fireEvent.change(input, { target: { value: 'sk-new' } })
|
||||
expect(lastDraft(onChange)).toEqual({ apiKey: 'sk-new' })
|
||||
})
|
||||
|
||||
it('clears a typed-but-unsaved secret back to unset', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ apiKey: 'sk-draft' }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.value).toBe('sk-draft')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(lastDraft(onChange)).toEqual({})
|
||||
})
|
||||
|
||||
it('reports an unset secret slot', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
secrets={[{ path: ['apiKey'], set: false }]}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
const input = container.querySelector('input[type="password"]') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Not configured')
|
||||
})
|
||||
|
||||
it('lets renderField replace a role-tagged control', () => {
|
||||
render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{ apiKeyEnv: 'OPENAI_API_KEY' }}
|
||||
onChange={vi.fn()}
|
||||
renderField={(context) => {
|
||||
if (context.role !== 'credential-ref') return undefined
|
||||
return <div data-testid="credential-control">{String(context.draftValue)}</div>
|
||||
}}
|
||||
/>)
|
||||
expect(screen.getByTestId('credential-control').textContent).toBe('OPENAI_API_KEY')
|
||||
})
|
||||
|
||||
it('disables every control under disabled', () => {
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Profile)}
|
||||
draft={{}}
|
||||
disabled
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
for (const input of container.querySelectorAll('input, select, button')) {
|
||||
expect((input as HTMLInputElement).disabled).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('containers', () => {
|
||||
const Catalog = Schema.object({
|
||||
models: Schema.array(Schema.object({ id: Schema.string().required() })),
|
||||
retryPolicy: Schema.object({ maxRetries: Schema.number() }),
|
||||
})
|
||||
|
||||
it('renders nested object groups', () => {
|
||||
render(<SchemaForm schema={Wire(Catalog)} draft={{}} onChange={vi.fn()} />)
|
||||
expect(screen.getByText('retryPolicy')).toBeTruthy()
|
||||
expect(screen.getByText('maxRetries')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('materializes fallback rows into the draft on add and edit', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Catalog)}
|
||||
draft={{}}
|
||||
fallback={{ models: [{ id: 'flash' }] }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'flash' }, {}] })
|
||||
fireEvent.change(screen.getByPlaceholderText('Default: flash'), { target: { value: 'pro' } })
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
|
||||
})
|
||||
|
||||
it('removes draft array rows wholesale', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Catalog)}
|
||||
draft={{ models: [{ id: 'flash' }, { id: 'pro' }] }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
fireEvent.click(screen.getAllByText('Remove')[0] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
|
||||
})
|
||||
|
||||
it('renders dict rows from both layers with removal only for draft keys', () => {
|
||||
const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Providers)}
|
||||
draft={{ providers: { openai: { baseURL: 'https://o' } } }}
|
||||
fallback={{ providers: { anthropic: { baseURL: 'https://a' }, openai: { baseURL: 'https://o' } } }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
expect(screen.getByText('anthropic')).toBeTruthy()
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
const removes = screen.getAllByText<HTMLButtonElement>('Remove')
|
||||
expect(removes.map(button => button.disabled)).toEqual([true, false])
|
||||
fireEvent.click(removes[1] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ providers: {} })
|
||||
})
|
||||
|
||||
it('adds dict entries through a free-text key input', () => {
|
||||
const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm schema={Wire(Providers)} draft={{}} onChange={onChange} />)
|
||||
const add = screen.getByLabelText<HTMLInputElement>('Add')
|
||||
fireEvent.keyDown(add, { key: 'a' })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
add.value = 'openai'
|
||||
fireEvent.keyDown(add, { key: 'Enter' })
|
||||
expect(lastDraft(onChange)).toEqual({ providers: { openai: {} } })
|
||||
add.value = ''
|
||||
fireEvent.keyDown(add, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers remaining sKey vocabulary as the add select', () => {
|
||||
const Providers = Schema.object({
|
||||
providers: Schema.dict(Schema.object({ baseURL: Schema.string() }), Schema.union(['openai', 'anthropic'])),
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm
|
||||
schema={Wire(Providers)}
|
||||
draft={{ providers: { openai: {} } }}
|
||||
onChange={onChange}
|
||||
/>)
|
||||
const add = screen.getByLabelText<HTMLSelectElement>('Add')
|
||||
expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic'])
|
||||
fireEvent.change(add, { target: { value: 'anthropic' } })
|
||||
expect(lastDraft(onChange)).toEqual({ providers: { openai: {}, anthropic: {} } })
|
||||
})
|
||||
|
||||
it('materializes type-shaped empty values for every array inner kind', () => {
|
||||
const Kinds = Schema.object({
|
||||
tags: Schema.array(Schema.string()),
|
||||
nums: Schema.array(Schema.number()),
|
||||
flags: Schema.array(Schema.boolean()),
|
||||
lists: Schema.array(Schema.array(Schema.string())),
|
||||
dicts: Schema.array(Schema.dict(Schema.string())),
|
||||
})
|
||||
const onChange = vi.fn()
|
||||
render(<SchemaForm schema={Wire(Kinds)} draft={{}} onChange={onChange} />)
|
||||
const adds = screen.getAllByText('Add')
|
||||
const expected: Record<string, unknown> = {
|
||||
tags: [''], nums: [0], flags: [false], lists: [[]], dicts: [{}],
|
||||
}
|
||||
Object.entries(expected).forEach(([key, value], index) => {
|
||||
fireEvent.click(adds[index] as HTMLElement)
|
||||
expect(lastDraft(onChange)).toEqual({ [key]: value })
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a read-only view for unsupported nodes instead of dropping them', () => {
|
||||
const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
|
||||
render(<SchemaForm
|
||||
schema={Wire(Mixed)}
|
||||
draft={{}}
|
||||
fallback={{ weird: 42 }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
expect(screen.getByText('42')).toBeTruthy()
|
||||
expect(screen.getByText(/no form control/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the draft value in the read-only fallback view, and nothing when both layers are empty', () => {
|
||||
const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
|
||||
const { container } = render(<SchemaForm
|
||||
schema={Wire(Mixed)}
|
||||
draft={{ weird: 'overridden' }}
|
||||
onChange={vi.fn()}
|
||||
/>)
|
||||
expect(screen.getByText('"overridden"')).toBeTruthy()
|
||||
cleanup()
|
||||
const empty = render(<SchemaForm schema={Wire(Mixed)} draft={{}} onChange={vi.fn()} />).container
|
||||
expect((empty.querySelector('pre') as HTMLElement).textContent).toBe('')
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a structural object node without declared properties as an empty group', () => {
|
||||
const { container } = render(<SchemaForm schema={{ type: 'object' }} draft={{}} onChange={vi.fn()} />)
|
||||
expect(container.querySelectorAll('input')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* schema-form is browser-only, but its lib bundle is imported under plain
|
||||
* Node through consumer lib chains (same posture as ui-primitives). CSS
|
||||
* imports are stubbed to empty modules: the hashed class maps only matter in
|
||||
* bundler contexts, which compile src directly and never read lib.
|
||||
*/
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
plugins: [{
|
||||
name: 'dsh-css-stub',
|
||||
resolveId(source: string) {
|
||||
if (!source.endsWith('.css')) return null
|
||||
return `\0dsh-css-stub:${source}.mjs`
|
||||
},
|
||||
load(id: string) {
|
||||
if (!id.startsWith('\0dsh-css-stub:')) return null
|
||||
return 'export default {};'
|
||||
},
|
||||
}],
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Credential-reference control: renders the reference NAME as the editable
|
||||
* settings field, its configured state as a badge, and an inline write-only
|
||||
* key input that stores the value through `credentials.set`. The value never
|
||||
* renders back — the wire has no read path for it.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CredentialView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SchemaFieldContext } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props of {@link CredentialControl}. */
|
||||
export interface CredentialControlProps {
|
||||
/** The `apiKeyEnv` leaf position inside the provider editor's form. */
|
||||
context: SchemaFieldContext
|
||||
/** Credentials wire face. */
|
||||
credentials: IApiClient['credentials']
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** The effective reference name this control addresses. */
|
||||
function refOf(context: SchemaFieldContext): string | undefined {
|
||||
const value = context.draftValue ?? context.fallbackValue
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the credential-reference field with its live state and key input.
|
||||
* @param props - field context, wire face, and copy.
|
||||
* @returns the control column.
|
||||
*/
|
||||
export function CredentialControl(props: CredentialControlProps): ReactNode {
|
||||
const { context, credentials, t } = props
|
||||
const ref = refOf(context)
|
||||
const [state, setState] = useState<CredentialView | undefined>(undefined)
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setState(undefined)
|
||||
if (ref === undefined) return undefined
|
||||
void credentials.describe({ refs: [ref] }).then((response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setState(response.result.value.credentials[ref])
|
||||
})
|
||||
return () => { stale = true }
|
||||
}, [credentials, ref])
|
||||
|
||||
const badge = state === undefined
|
||||
? null
|
||||
: state.configured
|
||||
? (
|
||||
<span className={styles['badgeOk']}>
|
||||
{t('credentialConfigured')}
|
||||
{state.source === 'env' ? ` · ${t('credentialFromEnv')}` : ''}
|
||||
</span>
|
||||
)
|
||||
: <span className={styles['badgeWarn']}>{t('credentialMissing')}</span>
|
||||
|
||||
const storeKey = async (): Promise<void> => {
|
||||
/* v8 ignore next -- the save button is disabled while no reference or draft exists */
|
||||
if (ref === undefined || keyDraft.length === 0) return
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
const response = await credentials.set({ ref, value: keyDraft })
|
||||
setBusy(false)
|
||||
if (!response.result.ok) {
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
}
|
||||
setKeyDraft('')
|
||||
const described = await credentials.describe({ refs: [ref] })
|
||||
if (described.result.ok) setState(described.result.value.credentials[ref])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['credential']}>
|
||||
<div className={styles['credentialRefRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
|
||||
placeholder={typeof context.fallbackValue === 'string' ? context.fallbackValue : undefined}
|
||||
aria-label={t('credentialRef')}
|
||||
disabled={context.disabled}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
if (next === '') context.clearValue()
|
||||
else context.setValue(next)
|
||||
}}
|
||||
/>
|
||||
{badge}
|
||||
</div>
|
||||
{ref !== undefined && state?.writable !== false
|
||||
? (
|
||||
<div className={styles['credentialKeyRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
disabled={context.disabled || busy}
|
||||
aria-label={t('keyInput')}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
disabled={context.disabled || busy || keyDraft.length === 0}
|
||||
onClick={() => { void storeKey() }}
|
||||
>
|
||||
{t('keySave')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -60,10 +60,21 @@
|
||||
}
|
||||
|
||||
.badgeOk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-success, #0a7d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badgeOk::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
color: var(--text-tertiary, #999);
|
||||
font-size: 12px;
|
||||
@@ -115,16 +126,19 @@
|
||||
}
|
||||
|
||||
.editor {
|
||||
border-top: 1px solid var(--border, #eee);
|
||||
padding-top: 12px;
|
||||
border: 1px solid var(--border, #e6e6e6);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-secondary, #f7f7f8);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editorTitle {
|
||||
@@ -132,6 +146,48 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editorRoute {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-tertiary, #888);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.advancedHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -144,43 +200,82 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addSelect {
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.credential {
|
||||
.addButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.addCard,
|
||||
.setupCard {
|
||||
border: 1px solid var(--border, #e6e6e6);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-secondary, #f7f7f8);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.credentialRefRow,
|
||||
.credentialKeyRow {
|
||||
.addCard .editor,
|
||||
.setupCard .editor {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.customized {
|
||||
border-top: 1px solid var(--border, #ececec);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.customizedSummary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.customizedBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credentialRefRow > input,
|
||||
.credentialKeyRow > input {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-strong, #111);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary, #aaa);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time (edit an existing provider or add a dormant one). Every
|
||||
* card at a time. A whole-section provider without a configured key (the
|
||||
* unconfigured DeepSeek posture) renders as its open setup card instead of a
|
||||
* row; the add flow is a card carrying the dormant-provider select. Every
|
||||
* mutation writes through the wire; the page re-renders from the pushed
|
||||
* invalidations or the post-apply reload.
|
||||
*/
|
||||
@@ -22,7 +24,7 @@ export interface ModelsSectionInjected {
|
||||
controller: ModelsSettingsStore
|
||||
/** uSES subscription hook bound to the store. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Wire faces the editor and credential control write through. */
|
||||
/** Wire faces the editor writes through. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
@@ -37,6 +39,7 @@ export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
provider: string
|
||||
displayName: string
|
||||
settingsNs: string
|
||||
settingsPath: readonly string[]
|
||||
}
|
||||
@@ -62,17 +65,28 @@ export async function removeProviderProfile(
|
||||
if (response.result.ok) await controller.load()
|
||||
}
|
||||
|
||||
function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['t'] }): ReactNode {
|
||||
return (
|
||||
<span className={styles['badges']}>
|
||||
{row.entry.active
|
||||
? <span className={styles['badgeOk']}>{t('active')}</span>
|
||||
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
|
||||
{row.credential !== undefined && !row.credential.configured
|
||||
? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
|
||||
: null}
|
||||
</span>
|
||||
)
|
||||
/**
|
||||
* Whether a whole-section provider still needs its first key: nothing marks
|
||||
* the credential configured and no literal `apiKey` is stored, so the page
|
||||
* opens the setup card instead of showing a row.
|
||||
* @param row - the joined provider row.
|
||||
* @param namespace - the owning namespace view.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow, namespace: SettingsNamespaceView): boolean {
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
if (row.credential?.configured === true) return false
|
||||
return !namespace.secrets.some(secret =>
|
||||
secret.set && secret.path.length === 1 && secret.path[0] === 'apiKey')
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
return {
|
||||
provider: row.entry.provider,
|
||||
displayName: row.entry.displayName,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,20 +138,38 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
<ul className={styles['rows']}>
|
||||
{configured.map((row) => {
|
||||
const target: EditorTarget = {
|
||||
provider: row.entry.provider,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const target = targetOf(row)
|
||||
const namespace = state.namespaces.get(target.settingsNs)
|
||||
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
||||
if (namespace === undefined) return null
|
||||
if (needsSetup(row, namespace)) {
|
||||
// First-run posture: the provider exists but has no key — the
|
||||
// setup card IS its presence on the page.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['rowCard']}>
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
<StatusBadges row={row} t={t} />
|
||||
<span className={styles['badges']}>
|
||||
{row.entry.active
|
||||
? <span className={styles['badgeOk']}>{t('active')}</span>
|
||||
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
|
||||
</span>
|
||||
<span className={styles['rowActions']}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,6 +196,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
@@ -180,38 +213,54 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<div className={styles['addBlock']}>
|
||||
{addTarget !== undefined && addNamespace !== undefined
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={addTarget.provider}
|
||||
namespace={addNamespace}
|
||||
settingsPath={addTarget.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
<div className={styles['addCard']}>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('provider')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
value={addTarget.provider}
|
||||
aria-label={t('provider')}
|
||||
onChange={(event) => {
|
||||
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
|
||||
/* v8 ignore next -- the select only lists addable rows */
|
||||
if (row === undefined) return
|
||||
setEditing(targetOf(row))
|
||||
}}
|
||||
>
|
||||
{addable.map(row => (
|
||||
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ProviderEditor
|
||||
key={addTarget.provider}
|
||||
provider={addTarget.provider}
|
||||
displayName={addTarget.displayName}
|
||||
hideTitle
|
||||
namespace={addNamespace}
|
||||
settingsPath={addTarget.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<select
|
||||
className={styles['addSelect']}
|
||||
value=""
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={addable.length === 0 || !state.writable}
|
||||
aria-label={t('add')}
|
||||
onChange={(event) => {
|
||||
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
|
||||
if (row === undefined) return
|
||||
onClick={() => {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setAdding(true)
|
||||
setEditing({
|
||||
provider: row.entry.provider,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
})
|
||||
setEditing(targetOf(first))
|
||||
}}
|
||||
>
|
||||
<option value="">{`+ ${t('add')}`}</option>
|
||||
{addable.map(row => (
|
||||
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
{`+ ${t('add')}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
/**
|
||||
* One provider's editor card: the schema-driven form over its profile
|
||||
* subtree, the credential-reference control, and the Apply/Cancel pair.
|
||||
* Apply without removals merges (`settings.update`, preserving stored keys
|
||||
* outside the patch); apply after a field reset replaces the user section so
|
||||
* the reset actually lands.
|
||||
* One provider's editor card, hand-written per adapter family: the primary
|
||||
* field is a single write-only **API key** input (the page never asks for an
|
||||
* environment-variable name — a typed key stores 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`);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (deepseek:
|
||||
* `baseURL` + `reasoningEffort`; pi-ai: `reasoning`). Everything else stays
|
||||
* owned by `settings.yaml` — the folded hint says so. Profile edits land as a
|
||||
* minimal `settings.update` merge patch; clearing a field back to inherited
|
||||
* removes its key, so that apply replaces the user section (safe: the section
|
||||
* stores references, never key values).
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CredentialView, IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
getPath, nodeAtPath, rehydrateSchema, SchemaForm, setPath, validateDraft,
|
||||
deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { SchemaFormSecret } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { CredentialControl } from './CredentialControl.tsx'
|
||||
import { deriveKeyRef } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
|
||||
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
|
||||
|
||||
/** Reasoning vocabularies per layout; the empty option means "inherit". */
|
||||
const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
|
||||
deepseek: ['off', 'high', 'max'],
|
||||
'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
}
|
||||
|
||||
/** The draft key the effort select edits, per layout. */
|
||||
const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
|
||||
deepseek: 'reasoningEffort',
|
||||
'pi-ai': 'reasoning',
|
||||
}
|
||||
|
||||
/** Props of {@link ProviderEditor}. */
|
||||
export interface ProviderEditorProps {
|
||||
/** Provider route id (card title). */
|
||||
/** Provider route id. */
|
||||
provider: string
|
||||
/** Display name for the card title. */
|
||||
displayName: string
|
||||
/** Hide the title row (the add card renders its own provider select). */
|
||||
hideTitle?: boolean
|
||||
/** The owning namespace view (schema, layers, secrets). */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
@@ -35,15 +59,6 @@ export interface ProviderEditorProps {
|
||||
onClose: (changed: boolean) => void
|
||||
}
|
||||
|
||||
/** Secrets re-rooted at the profile subtree (paths relative to the editor's form). */
|
||||
function secretsUnder(namespace: SettingsNamespaceView, path: readonly string[]): SchemaFormSecret[] {
|
||||
return namespace.secrets.flatMap((secret) => {
|
||||
if (secret.path.length < path.length) return []
|
||||
if (!path.every((key, index) => secret.path[index] === key)) return []
|
||||
return [{ path: secret.path.slice(path.length), set: secret.set }]
|
||||
})
|
||||
}
|
||||
|
||||
/** A user-section subtree as a plain draft object (absent → empty). */
|
||||
function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
|
||||
const subtree = getPath(namespace.user, path)
|
||||
@@ -51,10 +66,16 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
|
||||
return structuredClone(subtree) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Whether any key present in `before` is absent from `after` (a reset happened). */
|
||||
function removedAny(before: unknown, after: unknown): boolean {
|
||||
/**
|
||||
* Whether any key present in `before` is absent from `after` (a reset
|
||||
* happened somewhere in the draft, so the apply must replace, not merge).
|
||||
* @param before - the user-layer subtree the draft started from.
|
||||
* @param after - the edited draft.
|
||||
* @returns whether a removal exists at any depth.
|
||||
*/
|
||||
export function removedAny(before: unknown, after: unknown): boolean {
|
||||
if (typeof before !== 'object' || before === null) return false
|
||||
/* v8 ignore next -- the form edits containers in place; a container cannot become a primitive */
|
||||
/* v8 ignore next -- the editor edits containers in place; a container cannot become a primitive */
|
||||
if (typeof after !== 'object' || after === null) return true
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (!(key in (after as Record<string, unknown>))) return true
|
||||
@@ -63,6 +84,22 @@ function removedAny(before: unknown, after: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** The editor layout the owning namespace selects. */
|
||||
function layoutOf(ns: string): EditorLayout {
|
||||
if (ns === 'llm-deepseek') return 'deepseek'
|
||||
if (ns === 'llm-pi-ai') return 'pi-ai'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** The credential reference this profile resolves keys through. */
|
||||
function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string {
|
||||
const profile = getPath(namespace.value, path)
|
||||
const named = typeof profile === 'object' && profile !== null
|
||||
? (profile as { apiKeyEnv?: unknown }).apiKeyEnv
|
||||
: undefined
|
||||
return typeof named === 'string' && named.length > 0 ? named : deriveKeyRef(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider's editing card.
|
||||
* @param props - the addressed profile plus wire faces and copy.
|
||||
@@ -71,79 +108,175 @@ function removedAny(before: unknown, after: unknown): boolean {
|
||||
export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const { namespace, settingsPath, api, t } = props
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const subtreeSchema = useMemo(() => node?.toJSON(), [node])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
const secrets = useMemo(() => secretsUnder(namespace, settingsPath), [namespace, settingsPath])
|
||||
const disabled = props.readOnly || busy
|
||||
const layout = layoutOf(namespace.ns)
|
||||
const keyRef = refFor(namespace, settingsPath, props.provider)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setKeyState(undefined)
|
||||
void api.credentials.describe({ refs: [keyRef] }).then((response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setKeyState(response.result.value.credentials[keyRef])
|
||||
})
|
||||
return () => { stale = true }
|
||||
}, [api.credentials, keyRef])
|
||||
|
||||
const stringAt = (source: unknown, key: string): string | undefined => {
|
||||
const value = getPath(source, [key])
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
const setField = (key: string, next: string | undefined): void => {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
}
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
const needsReplace = removedAny(original, draft)
|
||||
// Merge patches stay minimal (just this profile); a replace must carry
|
||||
// the complete next user section because it lands wholesale.
|
||||
const patch = settingsPath.length === 0 ? draft : setPath({}, [...settingsPath], draft)
|
||||
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
|
||||
const nextSection = settingsPath.length === 0
|
||||
? draft
|
||||
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], draft)
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined) {
|
||||
const sectionError = settingsPath.length === 0 ? validateDraft(node, draft) : undefined
|
||||
if (sectionError !== undefined) {
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
// dormant add (or a legacy profile without one) records the derivation.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
const settingsChanged = JSON.stringify(next) !== JSON.stringify(original ?? {})
|
||||
if (settingsChanged) {
|
||||
const needsReplace = removedAny(original, next)
|
||||
// Merge patches stay minimal (just this profile); a replace must carry
|
||||
// the complete next user section because it lands wholesale.
|
||||
const patch = settingsPath.length === 0 ? next : setPath({}, [...settingsPath], next)
|
||||
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
|
||||
const nextSection = settingsPath.length === 0
|
||||
? next
|
||||
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], next)
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined) {
|
||||
const sectionError = settingsPath.length === 0 ? validateDraft(node, next) : undefined
|
||||
if (sectionError !== undefined) {
|
||||
setBusy(false)
|
||||
setFailure(sectionError)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = needsReplace
|
||||
? await api.settings.replace({ ns, section: nextSection })
|
||||
: await api.settings.update({ ns, patch })
|
||||
if (!response.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(sectionError)
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = needsReplace
|
||||
? await api.settings.replace({ ns, section: nextSection })
|
||||
: await api.settings.update({ ns, patch })
|
||||
setBusy(false)
|
||||
if (!response.result.ok) {
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (!stored.result.ok) {
|
||||
setBusy(false)
|
||||
setFailure(stored.result.error.message)
|
||||
return
|
||||
}
|
||||
setKeyDraft('')
|
||||
}
|
||||
setBusy(false)
|
||||
props.onClose(true)
|
||||
}
|
||||
|
||||
if (node === undefined || subtreeSchema === undefined) {
|
||||
if (node === undefined) {
|
||||
// A directory entry addressing a position its schema cannot resolve is a
|
||||
// host-side inconsistency; showing it beats a blank card.
|
||||
return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
|
||||
}
|
||||
|
||||
const keyLocked = keyState?.writable === false
|
||||
const effortField = layout === 'unknown' ? undefined : EFFORT_FIELD[layout]
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{props.provider}</span>
|
||||
</div>
|
||||
<SchemaForm
|
||||
schema={subtreeSchema}
|
||||
draft={draft}
|
||||
fallback={fallback}
|
||||
secrets={secrets}
|
||||
disabled={props.readOnly || busy}
|
||||
onChange={setDraft}
|
||||
labels={{
|
||||
reset: t('reset'),
|
||||
add: t('addLabel'),
|
||||
remove: t('removeLabel'),
|
||||
secretSet: t('secretSet'),
|
||||
secretUnset: t('secretUnset'),
|
||||
inherited: t('inherited'),
|
||||
unsupported: t('unsupported'),
|
||||
}}
|
||||
renderField={(context) => {
|
||||
if (context.role !== 'credential-ref') return undefined
|
||||
return <CredentialControl context={context} credentials={api.credentials} t={t} />
|
||||
}}
|
||||
/>
|
||||
{props.hideTitle === true
|
||||
? null
|
||||
: (
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{props.displayName}</span>
|
||||
{props.provider !== props.displayName
|
||||
? <span className={styles['editorRoute']}>{props.provider}</span>
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
{layout === 'unknown'
|
||||
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
|
||||
: (
|
||||
<>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{layout === 'deepseek'
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'baseURL') ?? ''}
|
||||
placeholder={stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* v8 ignore next -- EFFORT_FIELD is total over non-unknown layouts; the check only narrows the type */}
|
||||
{effortField !== undefined
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('effort')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
aria-label={t('effort')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField(effortField, event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
>
|
||||
<option value="">{t('effortInherit')}</option>
|
||||
{EFFORT_CHOICES[layout].map(choice => (
|
||||
<option key={choice} value={choice}>{choice}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
<div className={styles['editorActions']}>
|
||||
<button
|
||||
@@ -157,7 +290,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['primaryButton']}
|
||||
disabled={props.readOnly || busy}
|
||||
disabled={disabled || layout === 'unknown'}
|
||||
onClick={() => { void apply() }}
|
||||
>
|
||||
{busy ? t('applying') : t('apply')}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const en = {
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
active: 'Active',
|
||||
dormant: 'Inactive',
|
||||
keyMissing: 'No API key',
|
||||
edit: 'Edit',
|
||||
remove: 'Delete',
|
||||
add: 'Add provider',
|
||||
@@ -18,21 +17,16 @@ export const en = {
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
retry: 'Retry',
|
||||
credentialRef: 'API key environment variable',
|
||||
credentialConfigured: 'Configured',
|
||||
credentialFromEnv: 'from the launch environment (read-only)',
|
||||
credentialMissing: 'Not configured',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter a key to store it',
|
||||
keySave: 'Save key',
|
||||
keyClear: 'Clear key',
|
||||
reset: 'Reset',
|
||||
addLabel: 'Add',
|
||||
removeLabel: 'Remove',
|
||||
secretSet: 'Configured — enter a new value to replace',
|
||||
secretUnset: 'Not configured',
|
||||
inherited: 'Default',
|
||||
unsupported: 'This field has no form control; edit the settings document directly.',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyStored: 'Configured — enter a new value to replace',
|
||||
keyEnvLocked: 'Provided by the launch environment (read-only)',
|
||||
customized: 'Customized settings',
|
||||
baseUrl: 'Base URL',
|
||||
baseUrlDefault: 'Provider default',
|
||||
effort: 'Reasoning effort',
|
||||
effortInherit: 'Default',
|
||||
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
|
||||
}
|
||||
|
||||
/** Chinese strings (same keys as {@link en}). */
|
||||
@@ -42,7 +36,6 @@ export const zh: typeof en = {
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
active: '已启用',
|
||||
dormant: '未启用',
|
||||
keyMissing: '缺少密钥',
|
||||
edit: '编辑',
|
||||
remove: '删除',
|
||||
add: '添加提供方',
|
||||
@@ -53,19 +46,14 @@ export const zh: typeof en = {
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
retry: '重试',
|
||||
credentialRef: 'API 密钥环境变量',
|
||||
credentialConfigured: '已配置',
|
||||
credentialFromEnv: '来自启动环境(只读)',
|
||||
credentialMissing: '未配置',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入密钥以保存',
|
||||
keySave: '保存密钥',
|
||||
keyClear: '清除密钥',
|
||||
reset: '重置',
|
||||
addLabel: '添加',
|
||||
removeLabel: '移除',
|
||||
secretSet: '已设置——输入新值可替换',
|
||||
secretUnset: '未设置',
|
||||
inherited: '默认',
|
||||
unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyStored: '已配置——输入新值可替换',
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
baseUrl: 'API 地址',
|
||||
baseUrlDefault: '提供方默认',
|
||||
effort: '推理强度',
|
||||
effortInherit: '默认',
|
||||
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
|
||||
}
|
||||
@@ -40,6 +40,17 @@ export interface ModelsSettingsState {
|
||||
namespaces: ReadonlyMap<string, SettingsNamespaceView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the conventional credential reference for a provider route: the v1
|
||||
* page never asks for an environment-variable name, so a typed key stores
|
||||
* under this derived reference and the profile records it as `apiKeyEnv`.
|
||||
* @param provider - provider route id (e.g. `anthropic`, `minimax-cn`).
|
||||
* @returns the derived reference name (e.g. `MINIMAX_CN_API_KEY`).
|
||||
*/
|
||||
export function deriveKeyRef(provider: string): string {
|
||||
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
|
||||
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
|
||||
if (namespace === undefined) return undefined
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Section, editor, and credential-control behavior over a scripted wire face. */
|
||||
/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { removedAny } from '../src/client/ProviderEditor.tsx'
|
||||
import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import type { ProviderRow } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -20,6 +22,7 @@ const PiAiConfig = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
headers: Schema.dict(Schema.string()),
|
||||
})),
|
||||
})
|
||||
@@ -27,8 +30,8 @@ const PiAiConfig = Schema.object({
|
||||
const DeepSeekConfig = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
label: Schema.string().required(),
|
||||
baseURL: Schema.string().pattern(/^https:\/\//),
|
||||
reasoningEffort: Schema.union(['off', 'high', 'max']),
|
||||
})
|
||||
|
||||
function wireNamespaces(): SettingsNamespaceView[] {
|
||||
@@ -36,11 +39,21 @@ function wireNamespaces(): SettingsNamespaceView[] {
|
||||
{
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||||
base: { baseURL: 'https://base' },
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
|
||||
base: {},
|
||||
user: { reasoningEffort: 'high' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
},
|
||||
{
|
||||
ns: 'llm-plain',
|
||||
schema: JSON.parse(JSON.stringify(Schema.object({
|
||||
profiles: Schema.dict(Schema.object({ note: Schema.string() })),
|
||||
}).toJSON())) as unknown,
|
||||
value: {},
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
},
|
||||
{
|
||||
ns: 'llm-pi-ai',
|
||||
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
|
||||
@@ -68,8 +81,8 @@ function scriptedFace(overrides: {
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
@@ -80,6 +93,7 @@ function scriptedFace(overrides: {
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
|
||||
{ provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
|
||||
{ provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
|
||||
],
|
||||
}))),
|
||||
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
@@ -121,88 +135,242 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders configured rows with status badges and the add vocabulary', async () => {
|
||||
it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
|
||||
await mountSection()
|
||||
// DeepSeek has no configured credential and no stored apiKey → setup card.
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
|
||||
// Configured pi-ai profiles render as rows with liveness badges only.
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('anthropic', { selector: 'span' })).toBeNull()
|
||||
expect(screen.getAllByText(en.active)).toHaveLength(2)
|
||||
// A configured profile whose route did not register renders dormant.
|
||||
expect(screen.getAllByText(en.active)).toHaveLength(1)
|
||||
expect(screen.getByText(en.dormant)).toBeTruthy()
|
||||
expect(screen.getByText(en.keyMissing)).toBeTruthy()
|
||||
const add = screen.getByLabelText<HTMLSelectElement>(en.add)
|
||||
expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic', 'broken'])
|
||||
expect(screen.getAllByText(en.remove)).toHaveLength(2)
|
||||
expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
|
||||
const { update, face } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next' } })
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
cleanup()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={face as never}
|
||||
t={t}
|
||||
/>)
|
||||
// Now a row with an Edit button, not an open card.
|
||||
expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('decides setup need from the credential state and the stored apiKey slot', () => {
|
||||
const namespace = wireNamespaces()[0] as SettingsNamespaceView
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (credential: ProviderRow['credential']): ProviderRow =>
|
||||
({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential })
|
||||
expect(needsSetup(row(undefined), namespace)).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }), namespace)).toBe(false)
|
||||
const stored: SettingsNamespaceView = { ...namespace, secrets: [{ path: ['apiKey'], set: true }] }
|
||||
expect(needsSetup(row(undefined), stored)).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested, namespace)).toBe(false)
|
||||
})
|
||||
|
||||
it('derives conventional credential references from route ids', () => {
|
||||
expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
|
||||
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
||||
})
|
||||
|
||||
it('detects removals at any draft depth', () => {
|
||||
expect(removedAny({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true)
|
||||
expect(removedAny({ a: { b: 1 } }, { a: { b: 2 }, d: 3 })).toBe(false)
|
||||
expect(removedAny(undefined, {})).toBe(false)
|
||||
})
|
||||
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://next', headers: { 'X-Team': 'a' } } } },
|
||||
})
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
})
|
||||
|
||||
it('applies a field reset through replace so the removal lands', async () => {
|
||||
it('applies customized deepseek fields as a merge patch', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(baseURL.placeholder).toBe('https://base')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next2' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-deepseek',
|
||||
patch: { reasoningEffort: 'high', baseURL: 'https://next2' },
|
||||
})
|
||||
})
|
||||
|
||||
it('clears an inherited override through replace so the removal lands', async () => {
|
||||
const { replace, update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
|
||||
expect(effort.value).toBe('high')
|
||||
fireEvent.change(effort, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
section: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
})
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
|
||||
})
|
||||
|
||||
it('lands a nested removal (dict entry) through replace', async () => {
|
||||
const { replace } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
// Row deletion says "Delete"; the only "Remove" inside the open editor
|
||||
// is schema-form's headers-dict row control.
|
||||
fireEvent.click(screen.getAllByText(en.removeLabel)[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
const section = (replace.mock.calls[0]?.[0] as { section: { providers: { openai: { headers?: unknown } } } }).section
|
||||
expect(section.providers.openai.headers).toEqual({})
|
||||
it('falls back to the provider-default placeholder and clears typed input back to inherited', async () => {
|
||||
const { face } = scriptedFace()
|
||||
const bare: SettingsNamespaceView = {
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value: {},
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
}
|
||||
const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
|
||||
render(<ProviderEditor
|
||||
provider="deepseek-official"
|
||||
displayName="DeepSeek"
|
||||
namespace={bare}
|
||||
settingsPath={[]}
|
||||
api={face as never}
|
||||
t={t}
|
||||
readOnly={false}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(baseURL.placeholder).toBe(en.baseUrlDefault)
|
||||
fireEvent.change(baseURL, { target: { value: 'https://x' } })
|
||||
expect(baseURL.value).toBe('https://x')
|
||||
fireEvent.change(baseURL, { target: { value: '' } })
|
||||
expect(baseURL.value).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces a rejected apply inside the editor', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText('llm-pi-ai: unknown pi-ai provider "bogus"')
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('adds a dormant provider through the add select and merges its profile in', async () => {
|
||||
it('rejects an invalid draft before writing', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'anthropic' } })
|
||||
const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
|
||||
// No reference yet, so the write-only key input stays hidden until one exists.
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
fireEvent.change(ref, { target: { value: 'ANTHROPIC_API_KEY' } })
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(key.placeholder).toBe(en.keyPlaceholder)
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/baseURL/)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edits a pi-ai profile with the curated fields only', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
// The configured credential shows as the stored placeholder.
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
|
||||
// No Base URL for pi-ai; the only one on the page is the setup card's.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
expect(screen.getAllByLabelText(en.baseUrl)).toHaveLength(1)
|
||||
const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
|
||||
fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: {
|
||||
providers: {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' }, reasoning: 'xhigh' },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a dormant provider with a derived reference and stores its key', async () => {
|
||||
const { update, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
||||
expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
|
||||
expect(pick.value).toBe('anthropic')
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
|
||||
})
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
||||
})
|
||||
|
||||
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
|
||||
fireEvent.change(pick, { target: { value: 'broken' } })
|
||||
await screen.findByText(/unresolvable settings path/)
|
||||
fireEvent.change(pick, { target: { value: 'plain' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
|
||||
})
|
||||
// The hint-only card cannot apply anything.
|
||||
const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
|
||||
expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a rejected settings write and never stores the key after it', async () => {
|
||||
const { set } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
})
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await screen.findByText(/unknown pi-ai provider/)
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the card', async () => {
|
||||
await mountSection({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
})
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
})
|
||||
|
||||
it('locks the key input when the launch environment provides the credential', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
||||
configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
|
||||
}])),
|
||||
})))
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
|
||||
expect(editorKey.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a failed credential describe silent and the input usable', async () => {
|
||||
const { face, set } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('removes a user-added provider through replace', async () => {
|
||||
@@ -212,68 +380,6 @@ describe('ModelsSection', () => {
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
|
||||
})
|
||||
|
||||
it('reports an unresolvable settings path instead of a blank editor', async () => {
|
||||
await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'broken' } })
|
||||
await screen.findByText(/unresolvable settings path/)
|
||||
})
|
||||
|
||||
it('clears the credential reference back to inherited from the control', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
|
||||
expect(ref.value).toBe('OPENAI_API_KEY')
|
||||
fireEvent.change(ref, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(0) })
|
||||
// Dropping the reference is a removal, so it lands through replace.
|
||||
})
|
||||
|
||||
it('shows the env-shadowed credential badge and hides the key input', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(ok({
|
||||
credentials: { OPENAI_API_KEY: { configured: true, source: 'env', writable: false } },
|
||||
})))
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
await screen.findByText(content => content.includes(en.credentialFromEnv))
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no badge while the credential domain fails, and keeps a failed post-save describe quiet', async () => {
|
||||
const { face, set } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(screen.queryByText(en.credentialConfigured)).toBeNull()
|
||||
expect(screen.queryByText(en.credentialMissing)).toBeNull()
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
expect(key).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stores a credential value write-only and refreshes its badge', async () => {
|
||||
const { set, face } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
await waitFor(() => { expect(face.credentials.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect(key.value).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the control', async () => {
|
||||
await mountSection({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
const face = scriptedFace()
|
||||
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
|
||||
@@ -307,56 +413,30 @@ describe('ModelsSection', () => {
|
||||
/>)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
||||
expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles the editor closed on a second edit click and on cancel', async () => {
|
||||
it('toggles the row editor closed on a second edit click and on cancel', async () => {
|
||||
const { update } = await mountSection()
|
||||
const edit = screen.getAllByText(en.edit)[1] as HTMLElement
|
||||
const edit = screen.getAllByText(en.edit)[0] as HTMLElement
|
||||
fireEvent.click(edit)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
fireEvent.click(edit)
|
||||
expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
fireEvent.click(edit)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
|
||||
await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores the placeholder option of the add select', async () => {
|
||||
it('cancels the add card back to the add button', async () => {
|
||||
await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: '' } })
|
||||
expect(screen.queryByText(en.apply)).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a whole-section namespace (path []) as a direct patch', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
await screen.findByLabelText(en.credentialRef)
|
||||
const label = screen.getByPlaceholderText<HTMLInputElement>(/label|Default/i) ?? undefined
|
||||
const labelInput = screen.getAllByRole('textbox').find(input =>
|
||||
(input as HTMLInputElement).type === 'text'
|
||||
&& input.closest('div')?.previousElementSibling?.textContent?.includes('label') === true)
|
||||
const target = labelInput ?? screen.getAllByRole('textbox').at(-1)
|
||||
fireEvent.change(target as Element, { target: { value: 'Mine' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
const payload = update.mock.calls[0]?.[0] as { ns: string; patch: Record<string, unknown> }
|
||||
expect(payload.ns).toBe('llm-deepseek')
|
||||
expect(payload.patch['label']).toBe('Mine')
|
||||
expect(label ?? true).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects a section-level invalid draft before writing', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
await screen.findByLabelText(en.credentialRef)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
// schemastery names the missing required field in its failure text.
|
||||
await screen.findByText(/required/)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByText(`+ ${en.add}`))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
|
||||
await screen.findByText(`+ ${en.add}`)
|
||||
expect(screen.queryByLabelText(en.provider)).toBeNull()
|
||||
})
|
||||
|
||||
it('loads on first render of an idle controller', async () => {
|
||||
@@ -373,14 +453,14 @@ describe('ModelsSection', () => {
|
||||
|
||||
it('removes against a namespace with no user layer as an empty-section replace', async () => {
|
||||
const { face, replace, controller } = await mountSection()
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-deepseek')
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-plain')
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-deepseek', settingsPath: ['ghost-profile'] },
|
||||
{ settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
|
||||
namespace as NonNullable<typeof namespace>,
|
||||
)
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-plain', section: {} })
|
||||
})
|
||||
|
||||
it('keeps the snapshot untouched when a removal write is refused', async () => {
|
||||
|
||||
Generated
-6
@@ -956,9 +956,6 @@ importers:
|
||||
|
||||
packages/client/schema-form:
|
||||
dependencies:
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: 3.18.0
|
||||
@@ -966,9 +963,6 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
Reference in New Issue
Block a user