feat(web): declare a provider and its models from the Models page

The Models page could name a provider's key and little else. Adding an
OpenAI-compatible gateway meant opening $DSH_HOME/settings.yaml and
knowing the profile shape; correcting a stale context window meant the
same. This layer puts both on the page: a card that declares a route
pi-ai does not ship — id, endpoint, protocol, key, models — and a model
list on the pi-ai editor that can ask the provider what it serves and
adopt the answer.

It follows the DeepSeek catalog editor that landed in #1050 rather than
inventing a second look for the same job. Both editors now share the
section shell and heading, the danger-tinted delete, the add-model
button, the empty state, the per-row validator that names a bad row by
its position, and one K/M capacity vocabulary — 256K and 1M are read and
spelled back, while settings.yaml still stores plain token counts. The
row type is structurally open like that editor's, so a profile field
this card does not edit survives an edit here.

Three of that editor's decisions replaced weaker ones this branch had
made. Inheritance now reads the composition base rather than the
effective value, which would echo an override back the moment a reset
dropped it. Validation names the offending row instead of stating a
blanket problem. And emptying the list is no longer conflated with
handing the catalog back to the adapter — those are separate acts, with
separate affordances.

The create write carries the revision the card opened at, so a route
another tab declared meanwhile is a conflict rather than a silent
overwrite of its profile.
This commit is contained in:
Yichen Jiang
2026-08-05 18:53:37 +08:00
committed by imccyu
parent d97e150845
commit 44484ec5f6
21 changed files with 1982 additions and 60 deletions

View File

@@ -0,0 +1,240 @@
/**
* The card that declares a provider pi-ai does not ship — an OpenAI-compatible
* gateway, a self-hosted server, or a provider newer than the installed
* catalog.
*
* This is a create, not an edit, which is why it is its own card rather than
* the provider editor with extra fields: the route id is being *chosen* here,
* and the settings address does not exist until it is. One `settings.mutate`
* sets the whole profile at `providers.<route>`; the key travels separately
* through `credentials.set` under the reference the profile records, exactly as
* an existing provider's key does.
*
* The three fields a hand-declared route cannot default — endpoint, protocol,
* and at least one model — are required here rather than at load, so the
* failure names the field while the user is still looking at it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { EditorFooter } from './EditorFooter.tsx'
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import type { ModelDraft } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** The settings namespace a hand-declared provider is written into. */
const NS = 'llm-pi-ai'
/** A route id usable as a settings key and as the stem of a credential name. */
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/** Props of {@link CustomProviderCard}. */
export interface CustomProviderCardProps {
/** Route ids already declared, so the card refuses to shadow one. */
taken: readonly string[]
/** Wire protocols the adapter can serve, in the order it reports them. */
protocols: readonly string[]
/**
* Revision of the `llm-pi-ai` user section this card opened at, sent with
* the create so a route another tab declared meanwhile is a refusal rather
* than a silent overwrite of its whole profile.
*/
revision: number
/** Wire faces for the write and for interrogating the endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
readOnly: boolean
/** Close the card; `changed` reports whether a provider was created. */
onClose: (changed: boolean) => void
}
/**
* Render the custom-provider creation card.
* @param props - existing routes, protocol choices, wire faces, and copy.
* @returns the creation card.
*/
export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const { taken, protocols, api, t } = props
// Captured at mount, like the editor's: the write must be judged against the
// section this card was drafted over, not whatever it grew into meanwhile.
const [openedAt] = useState(() => props.revision)
const [route, setRoute] = useState('')
const [displayName, setDisplayName] = useState('')
const [baseURL, setBaseURL] = useState('')
const [protocol, setProtocol] = useState(protocols[0] ?? '')
const [keyDraft, setKeyDraft] = useState('')
const [models, setModels] = useState<readonly ModelDraft[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const disabled = props.readOnly || busy
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
const routeTaken = taken.includes(route)
// Rows are checked by the same per-row validator the editor cards use, so a
// bad row is named by its position here too. Capacities have route-level
// fallbacks; what a route cannot default is at least one model.
const modelFailure = validateDeepSeekModels(models)
const ready = route.length > 0 && !routeInvalid && !routeTaken
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
// The one blocked gate worth a line under the form. The route id is omitted
// because its own field already explains itself, and a satisfied card says
// nothing at all rather than printing an empty paragraph.
const hint = failure !== undefined || ready
? undefined
: baseURL.length === 0
? t('customNeedsBaseUrl')
: modelFailure !== undefined
? `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
: t('customNeedsModels')
/** Perform the create, returning a failure message or undefined. */
const createOnce = async (): Promise<string | undefined> => {
const keyRef = deriveKeyRef(route)
const profile = {
...displayName.length === 0 ? {} : { displayName },
apiKeyEnv: keyRef,
api: protocol,
baseURL,
models: models.map(model => ({ ...model })),
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
// The profile landed; saying the key did not is the only honest report,
// and the row is now editable so the key can be entered again there.
if (!stored.result.ok) return stored.result.error.message
}
return undefined
}
const create = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const outcome = await createOnce()
if (outcome !== undefined) {
setFailure(outcome)
return
}
props.onClose(true)
} catch (error) {
// A transport failure rejects rather than answering; without this the
// card would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
return (
<div className={styles['editor']}>
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{t('customTitle')}</span>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
<input
className={styles['input']}
type="text"
value={route}
placeholder="acme-gateway"
aria-label={t('customRoute')}
disabled={disabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
<p className={styles['advancedHint']}>
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
</p>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
className={styles['input']}
type="text"
value={displayName}
placeholder={route.length === 0 ? t('customDisplayName') : route}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setDisplayName(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
className={styles['input']}
type="text"
value={baseURL}
placeholder="https://gateway.example/v1"
aria-label={t('baseUrl')}
disabled={disabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={styles['input']}
value={protocol}
aria-label={t('customApi')}
disabled={disabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
<input
className={styles['input']}
type="password"
autoComplete="off"
value={keyDraft}
placeholder={t('keyPlaceholder')}
aria-label={t('keyInput')}
disabled={disabled}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
</div>
<ModelListEditor
models={models}
onChange={setModels}
probe={{
settingsNs: NS,
baseURL,
api: protocol,
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}}
api={api}
t={t}
disabled={disabled}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{/* Only the gates with something to say render; the route-id gate has its
own field-level hint, so its blocked state would print an empty line. */}
{hint === undefined ? null : <p className={styles['advancedHint']}>{hint}</p>}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || !ready}
submitLabel="create"
submitBusyLabel="creating"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void create() }}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
/**
* The action row every provider card ends with: dismiss on the left, commit on
* the right.
*
* The two cards commit different things — one creates a route, one edits an
* existing profile — but the row itself carries no such knowledge. It renders
* what it is handed, so the cards keep sole ownership of when a commit is
* allowed and what the in-flight wording is.
*
* Cancel refuses input only while a commit is in flight, never because the card
* is disabled: a card the deployment cannot write to must still be dismissable.
*
* @module dsh-client-ui-models/client/EditorFooter
*/
import type { ReactNode } from 'react'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Props of {@link EditorFooter}. */
export interface EditorFooterProps {
/** Localizer for the row's own labels. */
t: (key: keyof typeof en) => string
/** Whether a commit is in flight; holds Cancel and swaps the commit label. */
busy: boolean
/** Whether the commit is refused, as judged by the owning card. */
submitDisabled: boolean
/** Commit label while idle. */
submitLabel: keyof typeof en
/** Commit label while a commit is in flight. */
submitBusyLabel: keyof typeof en
/** Dismiss the card without committing. */
onCancel: () => void
/** Run the card's commit. */
onSubmit: () => void
}
/**
* Render one provider card's action row.
* @param props - the labels, commit gating, and handlers the owning card supplies.
* @returns the cancel/commit row.
*/
export function EditorFooter(props: EditorFooterProps): ReactNode {
const { t } = props
return (
<div className={styles['editorActions']}>
<button
type="button"
className={styles['secondaryButton']}
disabled={props.busy}
onClick={props.onCancel}
>
{t('cancel')}
</button>
<button
type="button"
className={styles['primaryButton']}
disabled={props.submitDisabled}
onClick={props.onSubmit}
>
{props.busy ? t(props.submitBusyLabel) : t(props.submitLabel)}
</button>
</div>
)
}

View File

@@ -0,0 +1,441 @@
/**
* The model list of one pi-ai provider profile, plus the action that asks the
* provider what it serves.
*
* The list is the profile's `models` array as the card holds it: an empty list
* means "serve this route's built-in catalog", and any entry replaces that
* catalog, so a row is only ever added deliberately. Fetching asks the endpoint
* **the form currently shows** — including a key typed but not yet saved — so
* adding a provider is one pass instead of save-then-return; the reply is
* candidates the user picks from, never configuration written behind them.
*
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
* with no readable listing) is not a dead end: the failure is shown next to the
* rows the user can still fill in by hand.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
import { messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/**
* One configured model row. Structurally open, exactly like the DeepSeek
* catalog editor's rows: a profile field this card does not edit — one a future
* schema adds, or one hand-written in `settings.yaml` — has to survive being
* edited here rather than being dropped by a rebuild.
*/
export type ModelDraft = DeepSeekModelDraft
/** A row's text field, or the empty string when unset or not a string. */
function textOf(model: ModelDraft, key: string): string {
const value = model[key]
return typeof value === 'string' ? value : ''
}
/** A row's numeric field, or `undefined` when unset or not a number. */
function numberOf(model: ModelDraft, key: string): number | undefined {
const value = model[key]
return typeof value === 'number' ? value : undefined
}
/** What an interrogation needs, taken from the live form. */
export interface ProbeTarget {
/** Settings namespace whose adapter family answers. */
settingsNs: string
/**
* Route being edited, when the card edits one. An adapter that already
* describes it answers from its own registry, so such a card can ask without
* an endpoint at all.
*/
provider?: string
/** Endpoint as the form currently shows it. */
baseURL?: string
/** Wire protocol the form names, when it names one. */
api?: string
/** Key typed into the form and not yet stored, when there is one. */
apiKey?: string
}
/** Props of {@link ModelListEditor}. */
export interface ModelListEditorProps {
/** The rows as currently drafted. */
models: readonly ModelDraft[]
/** Whether the user layer currently owns the whole array; absent on a create. */
overridden?: boolean
/** Replace the drafted rows. */
onChange: (models: ModelDraft[]) => void
/** Remove the user-owned array and return to inheritance; absent on a create. */
onReset?: () => void
/** Endpoint facts for the fetch action. */
probe: ProbeTarget
/** Wire face the fetch action calls. */
api: Pick<IApiClient, 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every control (read-only deployment or a pending write). */
disabled: boolean
}
/** Disclosure chevron; rotates to point down while its row is open. */
function IconChevron({ open }: { open: boolean }): ReactNode {
return (
<svg
width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden
style={{ transform: open ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
>
<path d="M6 3.5L10.5 8L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/** Removal glyph for one model row. */
function IconTrash(): ReactNode {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9a1 1 0 001 .9h4.6a1 1 0 001-.9L12 4M6.5 6.8v4.4M9.5 6.8v4.4"
stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"
/>
</svg>
)
}
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/**
* Spell a stored count for a field that may be unset. The spelling itself is
* {@link formatCapacity}, shared with the DeepSeek catalog editor so both
* surfaces read and write one K/M vocabulary.
* @param value - stored capacity, or `undefined` for an unset field.
* @returns the field text, empty when unset.
*/
function capacitySpelling(value: number | undefined): string {
return value === undefined ? '' : formatCapacity(value)
}
/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
function adopt(candidate: DiscoveredModelView): ModelDraft {
return {
id: candidate.id,
...candidate.name === undefined ? {} : { name: candidate.name },
...candidate.contextWindow === undefined ? {} : { contextWindow: candidate.contextWindow },
...candidate.maxTokens === undefined ? {} : { maxTokens: candidate.maxTokens },
}
}
/**
* Render the model list with its fetch action.
* @param props - the drafted rows, probe target, wire face, and copy.
* @returns the model-list editor.
*/
export function ModelListEditor(props: ModelListEditorProps): ReactNode {
const { models, onChange, probe, api, t, disabled } = props
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
const [candidates, setCandidates] = useState<readonly DiscoveredModelView[] | undefined>(undefined)
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
// Rows carry an id and a name; capacities are the exception, so they stay
// folded until asked for rather than crowding every row with four inputs.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
// Capacities are edited as text, so a field's keystrokes are held here rather
// than re-derived from the parsed count on every change — that would rewrite
// `1000` to `1K` mid-word. Unreadable text is kept past blur so the refusal
// names a row the user can still see, which is why this is one entry PER
// FIELD: a single buffer would be displaced by editing any other field, and
// the abandoned one would render its stored NaN as the literal `NaN`.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(new Map())
/** Buffer key for one capacity field; the row half moves when rows do. */
const bufferKey = (index: number, field: CapacityField): string => `${String(index)}:${field}`
const editCapacity = (index: number, field: CapacityField, text: string): void => {
setEditing(current => new Map(current).set(bufferKey(index, field), text))
patch(index, { [field]: parseCapacity(text) })
}
/** What a capacity field shows: the buffer while typing, else the stored count. */
const capacityText = (index: number, field: CapacityField): string =>
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(models[index] ?? {}, field))
/** Drop one row's entries and shift the rows after it down, in one pass. */
const reindexOnRemove = (
current: ReadonlyMap<string, string>,
index: number,
): Map<string, string> => {
const next = new Map<string, string>()
for (const [key, value] of current) {
const at = Number(key.slice(0, key.indexOf(':')))
if (at === index) continue
// Only the row number moves; the field half of the key is untouched.
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value)
}
return next
}
const toggleExpanded = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
const patch = (index: number, next: Record<string, string | number | undefined>): void => {
onChange(models.map((model, at) => {
if (at !== index) return model
// Rebuilt rather than spread over: an emptied optional field has to leave
// the profile, not be stored as a value its schema would reject.
// Spread first so a field this card does not edit survives; an emptied
// optional field is then dropped rather than stored as a value its
// schema would reject.
const cleared = new Set(
Object.entries(next).filter(([, value]) => value === undefined || value === '').map(([key]) => key),
)
return Object.fromEntries(
Object.entries({ ...model, ...next }).filter(([key]) => !cleared.has(key)),
)
}))
}
const fetchModels = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const response = await api.llm.discoverModels({
settingsNs: probe.settingsNs,
...probe.provider === undefined ? {} : { provider: probe.provider },
...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
...probe.api === undefined ? {} : { api: probe.api },
...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
})
if (!response.result.ok) {
setFailure(response.result.error.message)
return
}
const found = response.result.value.models
if (found.length === 0) {
setFailure(t('fetchEmpty'))
return
}
// Everything already configured starts unchecked, so adopting a
// selection never silently rewrites a capacity the user corrected.
const known = new Set(models.map(model => textOf(model, 'id')))
setCandidates(found)
setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
} catch (error) {
// The transport rejected rather than answering; without this the button
// would stay busy with nothing shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
const closePicker = (): void => {
setCandidates(undefined)
setPicked(new Set())
}
const adoptPicked = (): void => {
/* v8 ignore next -- the dialog only renders with candidates loaded */
if (candidates === undefined) return
const byId = new Map(models.map(model => [textOf(model, 'id'), model]))
for (const candidate of candidates) {
if (!picked.has(candidate.id)) continue
// A row the user already tuned wins over the provider's own numbers.
// Keyed by id, so a half-typed row whose id is still empty is not a
// match and the candidate joins as its own row — correct, since a row
// without an id is not yet a model and the create/apply gates refuse it.
byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate))
}
onChange([...byId.values()])
closePicker()
}
const toggle = (id: string): void => {
setPicked((current) => {
const next = new Set(current)
if (!next.delete(id)) next.add(id)
return next
})
}
// A route the adapter already describes answers without an endpoint; only a
// draft with neither has nothing to ask about.
const askable = probe.provider !== undefined || (probe.baseURL !== undefined && probe.baseURL.length > 0)
return (
<section className={styles['modelCatalog']} aria-label={t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{t('models')}</span>
{props.overridden === undefined
? null
: (
<span className={styles['modelCatalogMeta']}>
{props.overridden ? t('modelsCustomized') : t('modelsInherited')}
</span>
)}
</div>
{props.overridden === true && props.onReset !== undefined
? (
<button
type="button"
className={styles['linkButton']}
disabled={disabled}
onClick={props.onReset}
>
{t('resetModels')}
</button>
)
: null}
<button
type="button"
className={styles['linkButton']}
disabled={disabled || busy || !askable}
title={askable ? undefined : t('fetchNeedsBaseUrl')}
onClick={() => { void fetchModels() }}
>
{busy ? t('fetching') : t('fetchModels')}
</button>
</div>
{models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
{models.map((model, index) => (
<div key={index} className={styles['modelEntry']}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={textOf(model, 'id')}
placeholder={t('modelId')}
aria-label={`${t('modelId')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { id: event.target.value }) }}
/>
<input
className={styles['input']}
type="text"
value={textOf(model, 'name')}
placeholder={t('modelName')}
aria-label={`${t('modelName')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { patch(index, { name: event.target.value === '' ? undefined : event.target.value }) }}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${t('modelAdvanced')} ${index + 1}`}
aria-expanded={expanded.has(index)}
title={t('modelAdvanced')}
onClick={() => { toggleExpanded(index) }}
>
<IconChevron open={expanded.has(index)} />
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${t('removeModel')} ${index + 1}`}
title={t('removeModel')}
disabled={disabled}
onClick={() => {
onChange(models.filter((_model, at) => at !== index))
// Both stores are keyed by position, so every row after this
// one shifts down and would otherwise inherit its neighbour's
// state — a different row's capacities popping open, or its
// half-typed text appearing in another row's field.
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at < index) next.add(at)
else if (at > index) next.add(at - 1)
}
return next
})
setEditing(current => reindexOnRemove(current, index))
}}
>
<IconTrash />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelContextWindow')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(index, 'contextWindow')}
aria-label={`${t('modelContextWindow')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'contextWindow', event.target.value) }}
/>
</label>
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{t('modelMaxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(index, 'maxTokens')}
aria-label={`${t('modelMaxTokens')} ${index + 1}`}
disabled={disabled}
onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
/>
</label>
</div>
)
: null}
</div>
))}
<button
type="button"
className={styles['addModelButton']}
disabled={disabled}
onClick={() => { onChange([...models, { id: '' }]) }}
>
{t('addModel')}
</button>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
<Modal
open={candidates !== undefined}
onClose={closePicker}
title={t('fetchTitle')}
closeLabel={t('close')}
description={t('fetchDescription')}
className={styles['fetchDialog'] as string}
footer={(
<>
<Button variant="outline" onClick={closePicker}>{t('cancel')}</Button>
<Button variant="outline" onClick={adoptPicked}>{t('fetchAdopt')}</Button>
</>
)}
>
<ul className={styles['candidateList']}>
{(candidates ?? []).map(candidate => (
<li key={candidate.id} className={styles['candidate']}>
<label className={styles['candidateLabel']}>
<input
type="checkbox"
checked={picked.has(candidate.id)}
onChange={() => { toggle(candidate.id) }}
/>
<span className={styles['candidateId']}>{candidate.id}</span>
{candidate.contextWindow === undefined
? null
: <span className={styles['candidateMeta']}>{candidate.contextWindow}</span>}
</label>
</li>
))}
</ul>
</Modal>
</section>
)
}

View File

@@ -264,9 +264,21 @@
gap: 12px;
}
/* The two ways to gain a provider, as equal siblings spanning the same width
as the rows above. Wraps rather than shrinking below a legible label. */
.addActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.addButton {
/* Master's pill shape and glyph gap, sized to share the row equally so the
two ways to gain a provider read as siblings and line up with the rows
above rather than as two pills of different lengths. */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
align-self: flex-start;
}
@@ -571,4 +583,50 @@ select.input {
.customizedSummary::before {
transition: none;
}
.fetchDialog {
max-width: 520px;
/* The candidate list scrolls inside this dialog, an elevated surface, so the
scrollbar indirection is rebound here rather than on the scrolling child:
the elevation choice belongs with the surface and inherits down (see
ui-theme styles/scrollbar.css for the contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.candidateList {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 320px;
margin: 0;
overflow-y: auto;
padding: 0;
list-style: none;
}
.candidate {
border-radius: 6px;
}
.candidateLabel {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
}
.candidateId {
flex: 1 1 auto;
font-family: var(--dsh-font-mono, monospace);
font-size: 13px;
overflow-wrap: anywhere;
}
.candidateMeta {
color: var(--dsh-text-tertiary, #888);
font-size: 12px;
font-variant-numeric: tabular-nums;
}

View File

@@ -14,7 +14,8 @@ import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { messageOf } from './store.ts'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { messageOf, protocolChoices } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -27,7 +28,7 @@ export interface ModelsSectionInjected {
/** uSES subscription hook bound to the store. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Wire faces the editor writes through. */
api: Pick<IApiClient, 'settings' | 'credentials'>
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
@@ -118,10 +119,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [declaring, setDeclaring] = useState(false)
const closeEditor = (changed: boolean): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) void controller.load()
}
@@ -163,6 +166,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
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)
// Hand-declared routes live in the pi-ai namespace, which is also the only
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'))
return (
<div className={styles['section']}>
@@ -202,7 +209,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<button
type="button"
className={styles['secondaryButton']}
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
onClick={() => {
// One card at a time: leaving `declaring` set would show
// the create card beside this editor, and closing either
// one discards the other's draft.
setDeclaring(false)
setAdding(false)
setEditing(open ? undefined : target)
}}
>
{t('edit')}
</button>
@@ -274,24 +288,55 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
/>
</div>
)
: (
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
)}
: declaring
? (
<div className={styles['addCard']}>
<CustomProviderCard
taken={state.rows.map(row => row.entry.provider)}
protocols={protocols}
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
</div>
)
: (
// One row for the two ways to gain a provider: adopt one the
// adapter already knows, or declare one it does not. Side by side
// and equal-width so they read as siblings and line up with the
// rows above, rather than two pills of different lengths.
<div className={styles['addActions']}>
<button
type="button"
className={styles['addButton']}
disabled={addable.length === 0 || !state.writable}
onClick={() => {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setDeclaring(false)
setAdding(true)
setEditing(targetOf(first))
}}
>
{/* Same glyph as the composer's attach button. */}
<IconPlusOutline16 size={14} />
{t('add')}
</button>
<button
type="button"
className={styles['addButton']}
disabled={protocols.length === 0 || !state.writable}
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
>
<IconPlusOutline16 size={14} />
{t('customAdd')}
</button>
</div>
)}
</div>
<Modal
open={deleteTarget !== undefined}

View File

@@ -22,6 +22,8 @@ import {
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -56,8 +58,8 @@ export interface ProviderEditorProps {
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
settingsPath: readonly string[]
/** Wire faces for writes. */
api: Pick<IApiClient, 'settings' | 'credentials'>
/** Wire faces for writes and for interrogating a provider endpoint. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable writes (read-only settings provider). */
@@ -167,6 +169,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
}
// The model list is validated by the same per-row checker for both families,
// so a bad row is named by its position rather than by a blanket message.
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
// What the form currently shows, which is what an interrogation must ask:
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
const probeBaseURL = stringAt(draft, 'baseURL') ?? stringAt(fallback, 'baseURL')
const probe = {
settingsNs: namespace.ns,
// Naming the route lets an adapter that already describes it answer from
// its own registry — better metadata, no network call, no endpoint needed.
provider: props.provider,
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
...probeApi === undefined ? {} : { api: probeApi },
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
}
/**
* The write for this card, or a failure message. Every edit travels as
* path ops against the STORED section: the draft comes from the redacted
@@ -183,10 +201,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
if (layout === 'deepseek') {
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
if (modelFailure !== undefined) {
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
{
const failure = validateDeepSeekModels(getPath(next, ['models']))
if (failure !== undefined) {
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
}
}
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
@@ -263,6 +281,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
const catalogProps = {
models,
overridden: modelsOverridden,
t,
disabled,
onChange: (next: Record<string, unknown>[]) => {
setDraft(current => setPath(current, ['models'], next))
},
onReset: () => { setDraft(current => deletePath(current, ['models'])) },
}
return (
<>
<div className={styles['field']}>
@@ -316,22 +345,20 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
))}
</select>
</div>
{/* Both families edit the same rows through the same contract; only
the extras differ — DeepSeek's inherited capacities, pi-ai's
endpoint interrogation. */}
{family === 'deepseek'
? (
<DeepSeekModelsEditor
models={models}
overridden={modelsOverridden}
{...catalogProps}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
t={t}
disabled={disabled}
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
/>
)
: null}
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
</div>
</details>
</>
@@ -354,24 +381,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
: curatedFields(layout)}
{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={disabled || layout === 'unknown'}
onClick={() => { void apply() }}
>
{busy ? t('applying') : t('apply')}
</button>
</div>
{modelFailure === undefined
? null
: (
<p className={styles['advancedHint']}>
{`${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`}
</p>
)}
<EditorFooter
t={t}
busy={busy}
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
submitLabel="apply"
submitBusyLabel="applying"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void apply() }}
/>
</div>
)
}

View File

@@ -52,6 +52,29 @@ export const en = {
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
modelCapacityInvalid: 'A capacity must be a number, optionally suffixed K or M.',
modelDuplicate: 'Each model ID may appear once.',
modelContextWindow: 'Context window',
modelMaxTokens: 'Max output tokens',
fetchModels: 'Fetch available models',
fetching: 'Asking the provider\u2026',
fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
fetchEmpty: 'The provider listed no models. Add them by hand.',
fetchTitle: 'Choose models to add',
fetchDescription: 'These are the models the provider reports. Choose the ones to add; you can still edit their capacities afterwards.',
fetchAdopt: 'Add selected',
customAdd: 'Add a custom provider',
customTitle: 'Custom provider',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
customNeedsBaseUrl: 'A custom provider needs a base URL.',
customNeedsModels: 'A custom provider needs at least one model.',
create: 'Create provider',
creating: 'Creating\u2026',
onboardingTitle: 'Add an API key to get started',
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingGoToSettings: 'Go to settings',
@@ -113,6 +136,29 @@ export const zh: typeof en = {
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
modelDuplicate: '每个模型 ID 只能出现一次。',
modelContextWindow: '上下文窗口',
modelMaxTokens: '最大输出 token',
fetchModels: '获取可用模型',
fetching: '正在询问提供方\u2026',
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
fetchTitle: '选择要添加的模型',
fetchDescription: '以下是提供方报告的模型。勾选要添加的项,添加后仍可修改其容量。',
fetchAdopt: '添加所选',
customAdd: '添加自定义提供方',
customTitle: '自定义提供方',
customRoute: 'Provider ID',
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '只能使用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
create: '创建提供方',
creating: '创建中\u2026',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',

View File

@@ -11,7 +11,13 @@ import type {
} 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'
import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form'
/**
* Any route key walks a dict schema to the same profile node, so the lookup
* names one that cannot collide with a configured route.
*/
const PROBE_ROUTE = '\u0000probe'
/** One provider row the page renders. */
export interface ProviderRow {
@@ -66,6 +72,22 @@ export function deriveKeyRef(provider: string): string {
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
}
/**
* The wire protocols a hand-declared route may name, read out of the owning
* namespace's own schema. This stays a schema read rather than a wire field so
* the choices the page offers cannot drift from the ones the adapter accepts:
* both come from the same `Config`.
* @param namespace - the namespace view whose schema declares the profile shape.
* @returns the protocol identifiers, or an empty list when the schema has none.
*/
export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] {
if (namespace === undefined) return []
const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined)
if (list?.type !== 'union' || list.list === undefined) return []
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
}
/** 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