Merge remote-tracking branch 'origin/master' into worktree/provider-credential-lifecycle

# Conflicts:
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/ModelsSection.tsx
#	packages/client/ui-models/src/client/ProviderEditor.tsx
This commit is contained in:
Yichen Jiang
2026-08-06 16:54:33 +08:00
344 changed files with 12248 additions and 1317 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,459 @@
/**
* 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'
/**
* What an empty capacity field is worth, shown as its placeholder so a row left
* blank does not read as a model with no capacity at all.
*
* The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s
* `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person
* would say them. They are a hint, not a mirror: this page counts `K` as 1000,
* so typing `256K` stores 256000 while leaving the field blank keeps the
* adapter's 262144. A deployment that overrides those defaults is not
* reflected here — nothing on this page can read them.
*/
const CAPACITY_HINT: Readonly<Record<CapacityField, string>> = {
contextWindow: '256K',
maxTokens: '32K',
}
/**
* 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 = (model: ModelDraft, index: number, field: CapacityField): string =>
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, 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(model, index, 'contextWindow')}
placeholder={CAPACITY_HINT.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(model, index, 'maxTokens')}
placeholder={CAPACITY_HINT.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) }}
/>
{/* The id alone: it is the string adoption writes, and the
capacities the endpoint reported are adopted with it and
editable in the row that appears. */}
<span className={styles['candidateId']}>{candidate.id}</span>
</label>
</li>
))}
</ul>
</Modal>
</section>
)
}

View File

@@ -295,11 +295,26 @@
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 {
display: inline-flex;
align-items: center;
/* Overrides the shared button base above: these two are not pills sitting in
a footer but the last slot of the provider list, so they split the row
evenly and repeat the row cards' corner. Dashed, like every other "nothing
here yet" affordance on this page, to read as a place rather than a
command. */
flex: 1 1 0;
min-width: 180px;
gap: 6px;
align-self: flex-start;
height: 44px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
}
.addCard,
@@ -603,3 +618,44 @@ select.input {
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(--ds-font-family-code);
font-size: 13px;
overflow-wrap: anywhere;
}

View File

@@ -15,7 +15,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 { deriveKeyRef, messageOf } from './store.ts'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
@@ -28,7 +29,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
}
@@ -151,10 +152,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [deleting, setDeleting] = useState(false)
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
const [declaring, setDeclaring] = useState(false)
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) {
setSavedTarget(target)
void controller.load()
@@ -201,6 +204,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']}>
@@ -275,6 +282,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
aria-label={providerCopy(t('editProvider'), target)}
onClick={() => {
setSavedTarget(undefined)
// 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)
}}
@@ -354,25 +365,64 @@ 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
setSavedTarget(undefined)
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={(changed) => {
setDeclaring(false)
if (changed) void controller.load()
}}
/>
</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
setSavedTarget(undefined)
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={() => {
setSavedTarget(undefined)
setAdding(false)
setEditing(undefined)
setDeclaring(true)
}}
>
<IconPlusOutline16 size={14} />
{t('customAdd')}
</button>
</div>
)}
</div>
<Modal
open={deleteTarget !== undefined}

View File

@@ -24,6 +24,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'
@@ -58,8 +60,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). */
@@ -172,6 +174,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
@@ -188,10 +206,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
? 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)}`
{
// The same checker gates the submit button, so a card cannot reach this
// with a bad row; it stays because the schema check below would refuse
// the write with a message naming a path instead of the row, and because
// nothing but this function decides what is written.
const failure = validateDeepSeekModels(getPath(next, ['models']))
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
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 */
@@ -282,6 +305,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
: keyState?.configured === true
? t('keyStored')
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
/** 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']}>
@@ -333,22 +367,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>
</>
@@ -371,24 +403,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

@@ -59,6 +59,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 this provider has available. Choose the ones to add.',
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',
@@ -127,6 +150,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