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:
Yichen Jiang
2026-07-30 12:17:56 +08:00
parent 65bd54f8b4
commit d1bfdbff84
24 changed files with 779 additions and 1156 deletions
@@ -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')}
+18 -30
View File
@@ -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