feat(ui-models): schema-driven provider configuration page

The Models settings section joins llm.providers (the configurable
directory with live state), settings.describe (schemas, layered redacted
values, secret slots), and credentials.describe (value-free badges) into
provider rows with one editor card at a time. The editor renders the
provider's profile subtree through dsh-client-schema-form; the
credential-ref role mounts a control that shows configured/source state
and stores keys write-only through credentials.set. Apply without
removals merges a minimal patch (stored secrets outside it survive);
apply after a reset — and row deletion — replace the user section so
removals land. The client runtime bridges the three new host frames to
typed ctx events (settings/credentials/models changed), the page
refetches on any of them once loaded, and ui-model's per-session picker
directories reload on models/changed so a settings-born route appears in
open pickers without a reopen.
This commit is contained in:
Yichen Jiang
2026-07-30 00:45:55 +08:00
parent 9592c8f271
commit 686e40ebf6
21 changed files with 1750 additions and 27 deletions
@@ -0,0 +1,127 @@
/**
* 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>
)
}
@@ -0,0 +1,188 @@
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.intro {
margin: 0;
font-size: 13px;
color: var(--text-tertiary, #888);
}
.notice {
margin: 0;
font-size: 12px;
color: var(--text-warning, #a15c00);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.rowCard {
border: 1px solid var(--border, #e2e2e2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface, #fff);
}
.rowHead {
display: flex;
align-items: center;
gap: 10px;
}
.rowName {
font-size: 15px;
font-weight: 600;
}
.badges {
display: inline-flex;
gap: 6px;
flex: 1;
}
.badgeOk {
color: var(--text-success, #0a7d33);
font-size: 12px;
}
.badgeMuted {
color: var(--text-tertiary, #999);
font-size: 12px;
}
.badgeWarn {
color: var(--text-warning, #a15c00);
font-size: 12px;
}
.rowActions {
display: inline-flex;
gap: 8px;
}
.primaryButton {
border: none;
border-radius: 999px;
padding: 8px 18px;
background: var(--accent-strong, #111);
color: var(--text-inverse, #fff);
font: inherit;
cursor: pointer;
}
.secondaryButton {
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 6px 14px;
background: var(--surface, #fff);
color: inherit;
font: inherit;
cursor: pointer;
}
.dangerButton {
border: none;
background: none;
color: var(--text-danger, #c0392b);
font: inherit;
cursor: pointer;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.5;
cursor: default;
}
.editor {
border-top: 1px solid var(--border, #eee);
padding-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.editorHeader {
display: flex;
align-items: center;
}
.editorTitle {
font-size: 14px;
font-weight: 600;
}
.editorActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.addBlock {
display: flex;
flex-direction: column;
gap: 12px;
}
.addSelect {
align-self: flex-start;
border: 1px solid var(--border, #d9d9d9);
border-radius: 999px;
padding: 8px 14px;
font: inherit;
background: var(--surface, #fff);
}
.credential {
display: flex;
flex-direction: column;
gap: 6px;
}
.credentialRefRow,
.credentialKeyRow {
display: flex;
align-items: center;
gap: 8px;
}
.credentialRefRow > input,
.credentialKeyRow > input {
flex: 1;
}
.input {
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid var(--border, #d9d9d9);
border-radius: 8px;
font: inherit;
background: var(--surface, #fff);
color: inherit;
}
.error {
margin: 0;
font-size: 12px;
color: var(--text-danger, #c0392b);
}
@@ -1,13 +1,218 @@
/**
* Models settings section: an intentionally empty content column — the nav
* entry exists so the section slot composition is visible; model management
* lands in a later phase.
* 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
* mutation writes through the wire; the page re-renders from the pushed
* invalidations or the post-apply reload.
*/
/**
* Render the (empty) Models section content column.
* @returns null — no content this phase.
*/
export function ModelsSection() {
return null
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Injected dependencies of {@link ModelsSection} (slot `inject`). */
export interface ModelsSectionInjected {
/** The page store (loaded on mount, refreshed on pushed invalidations). */
controller: ModelsSettingsStore
/** uSES subscription hook bound to the store. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Wire faces the editor and credential control write through. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
/** Props delivered by the slot outlet. */
export interface ModelsSectionProps {
injected?: ModelsSectionInjected
}
/** The editor target: an existing row or a dormant directory entry. */
interface EditorTarget {
provider: string
settingsNs: string
settingsPath: readonly string[]
}
/**
* Remove one user-added provider profile from its namespace's user section
* (wholesale replace — merge cannot express a removal) and reload on success.
* @param api - settings wire face.
* @param controller - the page store to refresh.
* @param target - the provider's settings address.
* @param namespace - the owning namespace view.
* @returns settles when the write and any reload finished.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[] },
namespace: SettingsNamespaceView,
): Promise<void> {
const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
const next = deletePath(user, [...target.settingsPath])
const response = await api.settings.replace({ ns: target.settingsNs, section: next })
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>
)
}
/**
* Render the Models section content column.
* @param props - slot-delivered injected dependencies.
* @returns the section, or null while the shell has not injected yet.
*/
export function ModelsSection(props: ModelsSectionProps): ReactNode {
const injected = props.injected
if (injected === undefined) return null
return <Loaded injected={injected} />
}
function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const { controller, api, t } = injected
const state = injected.useSnapshot(snapshot => snapshot)
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
const [adding, setAdding] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
setAdding(false)
if (changed) void controller.load()
}
if (state.status === 'idle') void controller.load()
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
const errorText = state.error ?? ''
return (
<div className={styles['section']}>
<p className={styles['error']}>{`${t('loadFailed')}: ${errorText}`}</p>
<button type="button" className={styles['secondaryButton']} onClick={() => { void controller.load() }}>
{t('retry')}
</button>
</div>
)
}
const configured = state.rows.filter(row => row.configured)
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
const addTarget = adding ? editing : undefined
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
return (
<div className={styles['section']}>
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!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 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
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['rowActions']}>
<button
type="button"
className={styles['secondaryButton']}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
>
{t('edit')}
</button>
{row.removable
? (
<button
type="button"
className={styles['dangerButton']}
disabled={!state.writable}
onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
>
{t('remove')}
</button>
)
: null}
</span>
</div>
{open
? (
<ProviderEditor
provider={target.provider}
namespace={namespace}
settingsPath={target.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
)
: null}
</li>
)
})}
</ul>
<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}
/>
)
: (
<select
className={styles['addSelect']}
value=""
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
setAdding(true)
setEditing({
provider: row.entry.provider,
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
})
}}
>
<option value="">{`+ ${t('add')}`}</option>
{addable.map(row => (
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
))}
</select>
)}
</div>
</div>
)
}
@@ -0,0 +1,168 @@
/**
* 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.
*/
import { useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
getPath, nodeAtPath, rehydrateSchema, SchemaForm, 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 type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Props of {@link ProviderEditor}. */
export interface ProviderEditorProps {
/** Provider route id (card title). */
provider: string
/** The owning namespace view (schema, layers, secrets). */
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the editor; `changed` reports whether an Apply committed. */
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)
if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {}
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 {
if (typeof before !== 'object' || before === null) return false
/* v8 ignore next -- the form 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
if (removedAny(value, (after as Record<string, unknown>)[key])) return true
}
return false
}
/**
* Render one provider's editing card.
* @param props - the addressed profile plus wire faces and copy.
* @returns the editor card.
*/
export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const { namespace, settingsPath, api, t } = props
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
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 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) {
setBusy(false)
setFailure(sectionError)
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
}
props.onClose(true)
}
if (node === undefined || subtreeSchema === 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>
}
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} />
}}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={busy}
onClick={() => { props.onClose(false) }}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={props.readOnly || busy}
onClick={() => { void apply() }}
>
{busy ? t('applying') : t('apply')}
</button>
</div>
</div>
)
}
+55 -9
View File
@@ -1,44 +1,90 @@
/**
* Models settings section plugin, browser half. Registers the `models` nav
* entry into the shell-declared `settings.section` list slot; the content
* column is intentionally empty until model management lands. Export
* discipline: packages/client/AGENTS.md.
* entry into the shell-declared `settings.section` list slot and mounts the
* provider configuration page: the configurable-provider directory joined
* with settings namespaces and credential states, edited through the
* schema-driven form. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { ModelsSettingsStore } from './store.ts'
import { en, zh } from './locales.ts'
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
export type { ModelsSettingsState, ProviderRow } from './store.ts'
/**
* Refetch the page snapshot only after its first load: an unopened Models
* page must not fetch on background invalidations.
* @param controller - the page store.
*/
export function refreshIfLoaded(controller: ModelsSettingsStore): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}
/**
* Required services (cordis fiber inject). The target slot is declared by
* ui-settings' apply, whose activation order relative to this one is NOT
* constrained; registration goes through declaration-aware deferral.
*/
export const inject = ['slots', 'locale']
export const inject = ['slots', 'locale', 'connection']
/**
* Register the Models section once the `settings.section` declaration is on
* the ledger.
* the ledger, wire its store to the connection, and keep it fresh on every
* pushed invalidation (settings, credentials, or provider topology).
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register('settings.models', 'zh', { nav: '模型' }),
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
ctx.locale.register('settings.models', 'zh', zh),
ctx.locale.register('settings.models', 'en', en),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-models: nav copy dictionaries')
}, 'ui-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new ModelsSettingsStore(connection.api)
const useSnapshot = bindSnapshotSelector(controller.store)
const t = ctx.locale.bind('settings.models') as ModelsSectionInjected['t']
const injected = (): ModelsSectionInjected => ({
controller,
useSnapshot,
api: connection.api,
t,
})
// Pushed invalidations converge every open surface without polling: any
// settings/credentials/topology change refetches once the page loaded.
ctx.effect(() => {
const refresh = (): void => { refreshIfLoaded(controller) }
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('credentials/changed', refresh),
ctx.on('models/changed', refresh),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-models: pushed invalidations')
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: ctx.locale.bind('settings.models')('nav'),
label: t('nav'),
inject: injected,
}, ModelsSection))
// Nav labels are registrant-localized: refresh on locale change so the
// ledger carries fresh text (the version bump re-renders the shell).
@@ -0,0 +1,71 @@
/** Copy dictionaries for the Models settings section. */
/** English strings. */
export const en = {
nav: 'Models',
title: 'Models',
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',
provider: 'Provider',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
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.',
}
/** Chinese strings (same keys as {@link en}). */
export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
active: '已启用',
dormant: '未启用',
keyMissing: '缺少密钥',
edit: '编辑',
remove: '删除',
add: '添加提供方',
provider: '提供方',
cancel: '取消',
apply: '保存',
applying: '保存中…',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
retry: '重试',
credentialRef: 'API 密钥环境变量',
credentialConfigured: '已配置',
credentialFromEnv: '来自启动环境(只读)',
credentialMissing: '未配置',
keyInput: 'API 密钥',
keyPlaceholder: '输入密钥以保存',
keySave: '保存密钥',
keyClear: '清除密钥',
reset: '重置',
addLabel: '添加',
removeLabel: '移除',
secretSet: '已设置——输入新值可替换',
secretUnset: '未设置',
inherited: '默认',
unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
}
@@ -0,0 +1,136 @@
/**
* Models settings page store: one snapshot joining the configurable-provider
* directory (`llm.providers`), the settings namespaces (`settings.describe`),
* and the referenced credentials (`credentials.describe`). The host stays the
* single fact source — every mutation writes through the wire and the page
* re-renders from the next describe, pushed or refetched.
*/
import type {
ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
/** One provider row the page renders. */
export interface ProviderRow {
/** The directory entry (route id, display name, settings address, live state). */
entry: ConfigurableProviderView
/** Whether any layer configures this provider (its profile resolves). */
configured: boolean
/** Whether the user layer alone carries the profile (removal restores the base). */
removable: boolean
/** The credential reference the resolved profile names, when one does. */
apiKeyEnv: string | undefined
/** Credential state for {@link apiKeyEnv}, once described. */
credential: CredentialView | undefined
}
/** Page snapshot. */
export interface ModelsSettingsState {
status: 'idle' | 'loading' | 'ready' | 'error'
/** Whole-load failure text; row-level write failures stay in the editor. */
error: string | null
/** Whether the settings provider accepts writes. */
writable: boolean
/** Every configurable provider joined with its configured/credential state. */
rows: readonly ProviderRow[]
/** Namespace views by ns, for the editor's schema/layers/secrets. */
namespaces: ReadonlyMap<string, SettingsNamespaceView>
}
/** 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
const profile = getPath(namespace.value, path)
if (typeof profile !== 'object' || profile === null) return undefined
const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
}
/** The models settings page controller (one per settings surface). */
export class ModelsSettingsStore {
/** The snapshot the section renders from (uSES-safe store). */
readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
})
/** Latest load wins; an older response never overwrites a newer one. */
private generation = 0
/**
* @param api - the wire face (settings/credentials/llm domains).
*/
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
/**
* Refresh the whole page snapshot: directory and namespaces in parallel,
* then one batched credential describe over every referenced ref. A
* failure keeps the last good rows and surfaces the error.
* @returns nothing; the snapshot carries the outcome.
*/
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((s) => { s.status = 'loading'; s.error = null })
let providers: ConfigurableProviderView[]
let writable: boolean
let views: SettingsNamespaceView[]
try {
const [providersResponse, settingsResponse] = await Promise.all([
this.api.llm.providers({}),
this.api.settings.describe({}),
])
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message)
providers = providersResponse.result.value.providers
writable = settingsResponse.result.value.writable
views = settingsResponse.result.value.namespaces
} catch (error) {
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'error'
s.error = error instanceof Error ? error.message : String(error)
})
return
}
const namespaces = new Map(views.map(view => [view.ns, view]))
const rows: ProviderRow[] = providers.map((entry) => {
const namespace = namespaces.get(entry.settingsNs)
const configured = namespace !== undefined
&& (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined)
const removable = namespace !== undefined
&& entry.settingsPath.length > 0
&& hasPath(namespace.user, entry.settingsPath)
&& !hasPath(namespace.base, entry.settingsPath)
return {
entry,
configured,
removable,
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
credential: undefined,
}
})
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
let credentials: Record<string, CredentialView> = {}
if (refs.length > 0) {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment: rows render without it, so a
// missing credential provider degrades the badge, not the page.
if (response.result.ok) credentials = response.result.value.credentials
}
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'ready'
s.error = null
s.writable = writable
s.rows = rows.map(row => ({
...row,
...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
? { credential: credentials[row.apiKeyEnv] }
: {},
}))
s.namespaces = namespaces
})
}
}