Merge updated Models form from worktree-llm-web-config
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: 83a3d8c76ea52acd3a6ac1d76ea60bc8d3f614c0
|
||||
README.zh.md: 202cfa756265e330cc74bd97e5c40d086073e642
|
||||
README.md: de3c5b93e5f89e7e51236bea436ac71d226b6684
|
||||
README.zh.md: 52bb87a2867425d010630685a922556f144db4dd
|
||||
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek first-run credential overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); the add select's vocabulary is every dormant directory entry, so a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor renders the provider's profile subtree through [`@deepseek-ai/dsh-client-schema-form`](../schema-form); the `credential-ref` role mounts the credential control, which shows the reference's live state and stores key values **write-only** through `credentials.set` — no value ever renders back. A row is deletable only when the user layer alone carries it (removal restores the composition base).
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
|
||||
|
||||
The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. A mounted adapter with a writable missing reference opens the password form and writes only through `credentials.set`; success is accepted only after a fresh describe reports configured. An absent adapter is skipped because a browser form cannot mount Cordis plugins, while a present but unusable settings or credential capability produces a deployment diagnostic and an advanced link opens the Models section.
|
||||
|
||||
Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch (stored secrets outside the patch survive), while a field reset or row deletion lands through `settings.replace` of the whole user section so removals actually take effect. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Apply semantics mirror the settings seam: an edit without removals lands as a minimal `settings.update` merge patch, while clearing a fold field back to inherited or deleting a row lands through `settings.replace` of the whole user section so removals actually take effect — safe wholesale, because the section stores key references, never key values. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -21,5 +21,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A reset can drop a stored literal secret in the same subtree** — a replace-carried removal cannot re-supply secrets the wire never returned; store keys behind `credentials.*` references (the product default) and the case cannot arise.
|
||||
- **Only the API key and the curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); advanced fields (`models`, retry policy, timeouts…) are edited in `settings.yaml`, which the fold points at. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal replaces the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **No per-provider model listing on the page** — the picker surfaces models; this page shows route state only. A models preview per row is deferred until a consumer needs it.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和 DeepSeek 官方首次使用凭据浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);新增选择框的词汇是全部休眠目录条目,因此裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器经 [`@deepseek-ai/dsh-client-schema-form`](../schema-form) 渲染该提供方的 profile 子树;`credential-ref` 角色会挂载凭据控件,它展示该引用的实时状态,并经 `credentials.set` 以**只写**方式存入密钥值——任何值都绝不回显。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段(deepseek:`baseURL` + `reasoningEffort`;pi-ai:`reasoning`);其余每个 profile 字段仍归 `settings.yaml` 所有,折叠区上也会明说。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。
|
||||
|
||||
首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,浮层会打开密码表单,且只经 `credentials.set` 写入;只有重新调用 describe 并确认已配置后,才会接受此次提交。适配器缺失时直接跳过,因为浏览器表单无法挂载 Cordis 插件;提供方存在但设置或凭据能力不可用时,则显示部署诊断,并通过高级设置链接打开 Models 分区。
|
||||
|
||||
「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地(patch 之外已存储的 secret 得以保留),字段重置或整行删除则经对整个用户分节的 `settings.replace` 落地,使删除真正生效。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
「应用」语义与 settings seam 呈镜像:不含删除的编辑以最小的 `settings.update` 合并 patch 落地,把折叠区字段清回继承值或删除整行则经对整个用户分节的 `settings.replace` 落地,使删除真正生效——整体替换是安全的,因为该分节存的是密钥引用,从不存密钥值。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -21,5 +21,7 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **重置可能丢弃同一子树中已存储的字面 secret**:经 replace 承载的删除无法重新提供协议从未返回过的 secret;把密钥放在 `credentials.*` 引用背后(产品默认做法),该情形便不会出现。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));进阶字段(`models`、重试策略、超时……)在 `settings.yaml` 中编辑,折叠区会指向它。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除替换的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **页面上没有逐提供方的模型列表**:模型由选择器呈现;本页只展示路由状态。逐行的模型预览暂缓,待有消费方需要时再实现。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -17,16 +17,21 @@
|
||||
.label {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
box-sizing: border-box;
|
||||
padding-inline: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.input > input {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
|
||||
@@ -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,26 @@ 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.literalApiKeyConfigured && 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.
|
||||
* @returns whether to render the setup card.
|
||||
*/
|
||||
export function needsSetup(row: ProviderRow): boolean {
|
||||
if (row.entry.settingsPath.length > 0) return false
|
||||
if (row.credential?.configured === true) return false
|
||||
return !row.literalApiKeyConfigured
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
return {
|
||||
provider: row.entry.provider,
|
||||
displayName: row.entry.displayName,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,20 +136,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)) {
|
||||
// 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 +194,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
@@ -180,38 +211,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,53 @@
|
||||
/**
|
||||
* 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 (`baseURL` for
|
||||
* both families, plus `reasoningEffort` for deepseek / `reasoning` for
|
||||
* pi-ai). Everything else stays owned by `settings.yaml`. 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',
|
||||
}
|
||||
|
||||
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
|
||||
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
/** 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 +62,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 +69,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 +87,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 +111,172 @@ 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']}>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'baseURL') ?? ''}
|
||||
placeholder={layout === 'deepseek'
|
||||
? DEEPSEEK_PUBLIC_BASE_URL
|
||||
: stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 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}
|
||||
</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.',
|
||||
onboardingTitle: 'Add a DeepSeek API key',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingKey: 'API key',
|
||||
@@ -55,7 +49,6 @@ export const zh: typeof en = {
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
active: '已启用',
|
||||
dormant: '未启用',
|
||||
keyMissing: '缺少密钥',
|
||||
edit: '编辑',
|
||||
remove: '删除',
|
||||
add: '添加提供方',
|
||||
@@ -66,21 +59,16 @@ 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 中,请直接编辑对应段。',
|
||||
onboardingTitle: '添加 DeepSeek API 密钥',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingKey: 'API 密钥',
|
||||
|
||||
@@ -44,6 +44,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, ModelsSectionProps } 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: [] }))),
|
||||
@@ -127,98 +141,259 @@ describe('ModelsSection', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
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('does not mark a provider with a configured literal key as missing', async () => {
|
||||
const { controller } = await mountSection()
|
||||
controller.store.update((state) => {
|
||||
state.rows = state.rows.map(row => row.entry.provider === 'deepseek-official'
|
||||
? { ...row, literalApiKeyConfigured: true }
|
||||
: row)
|
||||
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 joined credential state and literal-key sidecar', () => {
|
||||
const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
|
||||
const row = (
|
||||
credential: ProviderRow['credential'],
|
||||
literalApiKeyConfigured = false,
|
||||
): ProviderRow => ({
|
||||
entry,
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'X',
|
||||
credential,
|
||||
literalApiKeyConfigured,
|
||||
})
|
||||
await waitFor(() => { expect(screen.queryByText(en.keyMissing)).toBeNull() })
|
||||
expect(needsSetup(row(undefined))).toBe(true)
|
||||
expect(needsSetup(row({ configured: true, writable: true }))).toBe(false)
|
||||
expect(needsSetup(row(undefined, true))).toBe(false)
|
||||
const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
|
||||
expect(needsSetup(nested)).toBe(false)
|
||||
})
|
||||
|
||||
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('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)
|
||||
// The deepseek placeholder is pinned to the public endpoint, not the
|
||||
// effective value (which may reflect a launch-environment override).
|
||||
expect(baseURL.placeholder).toBe('https://api.deepseek.com')
|
||||
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('pins the deepseek 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('https://api.deepseek.com')
|
||||
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) })
|
||||
// pi-ai carries Base URL too: the stored override shows as the value and
|
||||
// the effective profile endpoint as its placeholder source.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect(urls).toHaveLength(2)
|
||||
expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
|
||||
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')
|
||||
// A dormant profile has no endpoint anywhere: the pi-ai placeholder
|
||||
// falls back to the provider-default wording.
|
||||
fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
|
||||
const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
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 () => {
|
||||
@@ -228,68 +403,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
|
||||
@@ -323,56 +436,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 () => {
|
||||
@@ -389,14 +476,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 () => {
|
||||
|
||||
Reference in New Issue
Block a user