refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,296 @@
/**
* 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.
*
* There is deliberately no reasoning-effort control, here or on the editor
* card: effort is a per-MODEL capability, and the models under one provider
* disagree about it, so a provider-scoped control can only be set to a value
* some of them reject. The composer's model picker offers each model its own
* levels instead.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { apiKeyFailure } from './apiKey.ts'
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.
* The leading letter is the second half of that: `deriveKeyRef` uppercases the
* id and replaces every non-alphanumeric run with `_`, and a credential
* reference is a POSIX shell identifier, which cannot start with a digit. A
* digit-leading id passes every check this card makes and then fails at the
* credential seam with a raw regular expression the user cannot act on.
*/
const ROUTE_PATTERN = /^[a-z][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)
/**
* The profile write landed. Only the key write can still be outstanding, so
* the fields that describe the provider are settled and the retry path is
* the credential alone.
*/
const [committed, setCommitted] = useState(false)
const disabled = props.readOnly || busy
/** Everything but the key stops being editable once the provider exists. */
const profileDisabled = disabled || committed
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 keyFailure = apiKeyFailure(keyDraft)
// The typed key with paste whitespace removed. A blank field yields an empty
// string, which the create path reads as "no key supplied" — a route may
// legitimately authenticate through the provider's own ambient discovery.
const keyValue = keyDraft.trim()
const ready = route.length > 0 && !routeInvalid && !routeTaken
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
&& keyFailure === undefined
// The one blocked gate worth a line under the form. A satisfied card says
// nothing at all rather than printing an empty paragraph.
const hint = failure !== undefined || ready
// The key field prints its own failure directly beneath itself, so a card
// blocked only by the key stays silent here rather than answering with the
// next unmet gate — which is satisfied, and reads as a second, false fault.
|| keyFailure !== undefined
// Same for the route id, and it must be tested rather than assumed: the
// fallback arm below reads "no models yet", so an unmet route gate would
// fall through to it and contradict the filled-in list right above.
|| route.length === 0 || routeInvalid || routeTaken
? 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 storesKey = keyValue.length > 0
if (!committed) {
const profile = {
...displayName.length === 0 ? {} : { displayName },
// The profile names the conventional reference only when this card is
// about to store a key, matching the editor: a route declared with the
// key left blank keeps its provider-native auth path (a credential
// chain, ADC) instead of resolving a reference nothing ever sets.
...storesKey ? { 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
// The provider now exists. A retry after the key write below fails must
// not re-run this mutate: the revision it holds is the one this write
// just superseded, so the Host would answer `settings-conflict` and the
// key could never be stored from this card at all.
setCommitted(true)
}
if (storesKey) {
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
// The profile landed; saying the key did not is the only honest report,
// and the retry above now goes straight back to this write.
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={profileDisabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
{/* A rejected id reads as a fault, not as guidance — the same split the
key field below already makes between its failure and its hint. */}
{routeInvalid || routeTaken
? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p>
: <p className={styles['advancedHint']}>{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={profileDisabled}
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={profileDisabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={protocol}
aria-label={t('customApi')}
disabled={profileDisabled}
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) }}
/>
{/* A create card has no stored key to keep, so the blank case says
what a blank field means here instead: this route may authenticate
through the provider's own ambient discovery or OAuth. */}
{keyFailure === undefined
? null
: <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
</div>
<ModelListEditor
models={models}
onChange={setModels}
probe={{
settingsNs: NS,
baseURL,
api: protocol,
...keyValue.length === 0 ? {} : { apiKey: keyValue },
}}
probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
api={api}
t={t}
disabled={profileDisabled}
/>
{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(committed) }}
onSubmit={() => { void create() }}
/>
</div>
)
}

View File

@@ -0,0 +1,364 @@
/**
* Curated editor for the direct DeepSeek adapter's advisory model catalog.
* The settings layer replaces `models` as one array, so the parent supplies
* the effective inherited rows until the first edit materializes a user
* override; reset removes that override instead of copying defaults into it.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import {
IconChevronDownOutline14, IconChevronRightOutline14, IconPlusOutline16, IconTrashOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** One catalog entry kept structurally open so hidden or future fields survive an edit. */
export type DeepSeekModelDraft = Record<string, unknown>
/** The catalog fields this editor writes. */
type CatalogField = 'id' | 'name' | 'contextWindow' | 'maxTokens'
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
type CapacityField = 'contextWindow' | 'maxTokens'
/** Row index encoded in an editing-buffer key. */
function rowOf(key: string): number {
return Number(key.slice(0, key.indexOf(':')))
}
/** Accepted capacity spellings: a decimal count with an optional K/M suffix. */
const CAPACITY_PATTERN = /^(\d+(?:\.\d+)?)([km])?$/i
/** Decimal suffix scales — `1M` is 1000K, matching how model capacities are quoted. */
const CAPACITY_SCALE = { k: 1_000, m: 1_000_000 } as const
/**
* Read a typed capacity, so a user can write `256K` or `1M` instead of counting
* zeroes. The stored value stays a plain token count.
* @param text - raw field text.
* @returns the count; `undefined` when blank (inherit), `NaN` when unreadable
* (rejected by {@link validateDeepSeekModels} before any write).
*/
export function parseCapacity(text: string): number | undefined {
const trimmed = text.trim()
if (trimmed.length === 0) return undefined
const match = CAPACITY_PATTERN.exec(trimmed)
if (match === null) return Number.NaN
const suffix = match[2]?.toLowerCase()
const scale = suffix === 'k' || suffix === 'm' ? CAPACITY_SCALE[suffix] : 1
const scaled = Number(match[1]) * scale
// A decimal multiple is exact in intent but not in binary floating point
// (2.3 * 1e6 lands a few ULPs high), so an integral intent snaps back.
const rounded = Math.round(scaled)
return Math.abs(scaled - rounded) < 1e-6 ? rounded : scaled
}
/**
* Spell a stored count back in the shortest form that survives a round trip
* through {@link parseCapacity}; a count that is not a whole number of
* thousands stays written out.
* @param value - stored capacity.
* @returns the field text.
*/
export function formatCapacity(value: number): string {
if (!Number.isInteger(value) || value <= 0) return String(value)
if (value % CAPACITY_SCALE.m === 0) return `${String(value / CAPACITY_SCALE.m)}M`
if (value % CAPACITY_SCALE.k === 0) return `${String(value / CAPACITY_SCALE.k)}K`
return String(value)
}
/** A localized validation failure for one user-owned model array. */
export interface DeepSeekModelsValidationFailure {
/** Zero-based model position. */
index: number
/** Message key owned by the Models settings section. */
key: 'modelIdRequired' | 'modelIdDuplicate' | 'modelNameInvalid' | 'modelContextInvalid'
| 'modelMaxTokensInvalid'
}
/** Convert a schema-validated catalog value into records without dropping hidden fields. */
export function modelDrafts(value: unknown): DeepSeekModelDraft[] {
if (!Array.isArray(value)) return []
return value.map(entry =>
typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entry as DeepSeekModelDraft
: {})
}
/**
* Validate adapter constraints that the serialized schema cannot express.
* @param value - user-owned `models` value, or undefined while inherited.
* @returns the first invalid row, or undefined when the adapter will accept it.
*/
export function validateDeepSeekModels(value: unknown): DeepSeekModelsValidationFailure | undefined {
if (value === undefined) return undefined
const models = modelDrafts(value)
const seen = new Set<string>()
for (const [index, model] of models.entries()) {
// Compared trimmed: surrounding whitespace is a paste artifact the adapter
// would never match, and an untrimmed compare lets `model ` slip past the
// duplicate check against its own twin.
const id = model['id']
const trimmed = typeof id === 'string' ? id.trim() : undefined
if (trimmed === undefined || trimmed.length === 0) return { index, key: 'modelIdRequired' }
if (seen.has(trimmed)) return { index, key: 'modelIdDuplicate' }
seen.add(trimmed)
const name = model['name']
if (name !== undefined && (typeof name !== 'string' || name.length === 0)) {
return { index, key: 'modelNameInvalid' }
}
const contextWindow = model['contextWindow']
if (contextWindow !== undefined
&& (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0)) {
return { index, key: 'modelContextInvalid' }
}
const maxTokens = model['maxTokens']
if (maxTokens !== undefined
&& (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens <= 0)) {
return { index, key: 'modelMaxTokensInvalid' }
}
}
return undefined
}
/** Props of {@link DeepSeekModelsEditor}. */
export interface DeepSeekModelsEditorProps {
/** Effective rows: inherited until the parent materializes an override. */
models: readonly DeepSeekModelDraft[]
/** Whether the user layer currently owns the whole array. */
overridden: boolean
/** Fallback context capacity used when a row omits its exact value. */
defaultContextWindow: number | undefined
/** Fallback output cap used when a row omits its exact value. */
defaultMaxTokens: number | undefined
/** Section copy. */
t: (key: keyof typeof en) => string
/** Disable every mutation. */
disabled: boolean
/** Replace the user-owned array after one visible edit. */
onChange: (models: DeepSeekModelDraft[]) => void
/** Remove the user-owned array and return to inheritance. */
onReset: () => void
}
/**
* Render the direct DeepSeek adapter's model catalog: id and display name on
* each row, capacities behind the row's own disclosure.
* @param props - effective rows plus the array-level override actions.
* @returns the catalog editor.
*/
export function DeepSeekModelsEditor(props: DeepSeekModelsEditorProps): ReactNode {
// Capacities are edited as text, so a field's keystrokes are held here
// rather than re-derived from the parsed count on every change, which would
// rewrite `1000` to `1K` mid-word. Unreadable text is kept past blur so the
// save-time rejection names a row the user can still see — which is why
// this is one entry PER FIELD: a single active buffer would be displaced by
// editing any other field, and the abandoned one would fall back to
// rendering its stored NaN as the literal `NaN`.
//
// Keys carry the row index, so the two operations that move indexes maintain
// them: `remove` re-keys around the dropped row, and reset clears them all
// because the rows they annotated are gone.
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(() => new Map())
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set())
const update = (index: number, key: CatalogField, value: unknown): void => {
const next = props.models.map((model, at) => {
const copy = { ...model }
if (at !== index) return copy
if (value === undefined) Reflect.deleteProperty(copy, key)
else copy[key] = value
return copy
})
props.onChange(next)
}
const remove = (index: number): void => {
setEditing((current) => {
const next = new Map<string, string>()
for (const [key, text] of current) {
const at = rowOf(key)
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, text)
}
return next
})
setExpanded((current) => {
const next = new Set<number>()
for (const at of current) {
if (at === index) continue
next.add(at > index ? at - 1 : at)
}
return next
})
props.onChange(props.models.filter((_model, at) => at !== index).map(model => ({ ...model })))
}
const reset = (): void => {
setEditing(new Map())
setExpanded(new Set())
props.onReset()
}
const toggle = (index: number): void => {
setExpanded((current) => {
const next = new Set(current)
if (!next.delete(index)) next.add(index)
return next
})
}
/** The field's text: its live keystrokes, else the stored count spelled short. */
const capacityText = (model: DeepSeekModelDraft, index: number, field: CapacityField): string => {
const typed = editing.get(`${String(index)}:${field}`)
if (typed !== undefined) return typed
const value = model[field]
return typeof value === 'number' ? formatCapacity(value) : ''
}
const settleCapacity = (index: number, field: CapacityField): void => {
const key = `${String(index)}:${field}`
const typed = editing.get(key)
if (typed === undefined) return
// Unreadable text stays on screen: the save-time rejection names a row the
// user can still see and correct.
const parsed = parseCapacity(typed)
if (parsed !== undefined && Number.isNaN(parsed)) return
setEditing((current) => {
const next = new Map(current)
next.delete(key)
return next
})
}
/** One capacity field of one row, rendered inside the row's disclosure. */
const capacityField = (
model: DeepSeekModelDraft,
index: number,
field: CapacityField,
fallback: number | undefined,
): ReactNode => (
<label className={styles['modelField']}>
<span className={styles['modelFieldLabel']}>{props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')}</span>
<input
className={styles['input']}
type="text"
inputMode="numeric"
value={capacityText(model, index, field)}
placeholder={fallback === undefined
? props.t(field === 'contextWindow' ? 'contextWindowPlaceholder' : 'maxTokensPlaceholder')
: formatCapacity(fallback)}
aria-label={`${props.t(field === 'contextWindow' ? 'contextWindow' : 'maxTokens')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
const text = event.target.value
setEditing(current => new Map(current).set(`${String(index)}:${field}`, text))
update(index, field, parseCapacity(text))
}}
onBlur={() => { settleCapacity(index, field) }}
/>
</label>
)
return (
<section className={styles['modelCatalog']} aria-label={props.t('models')}>
<div className={styles['modelListHead']}>
<div className={styles['modelCatalogHeading']}>
<span className={styles['modelCatalogTitle']}>{props.t('models')}</span>
<span className={styles['modelCatalogMeta']}>
{props.overridden ? props.t('modelsCustomized') : props.t('modelsInherited')}
</span>
</div>
{props.overridden
? (
<button
type="button"
className={styles['linkButton']}
disabled={props.disabled}
onClick={reset}
>
{props.t('resetModels')}
</button>
)
: null}
</div>
{props.models.length === 0
? <p className={styles['modelEmpty']}>{props.t('modelsEmpty')}</p>
: (
<div className={styles['modelList']}>
{props.models.map((model, index) => (
<div className={styles['modelEntry']} key={index}>
<div className={styles['modelRow']}>
<input
className={styles['input']}
type="text"
value={typeof model['id'] === 'string' ? model['id'] : ''}
placeholder={props.t('modelId')}
aria-label={`${props.t('modelId')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => { update(index, 'id', event.target.value) }}
onBlur={(event) => {
// Settle a pasted id rather than trimming per keystroke,
// which would stop the user typing an interior space.
const trimmed = event.target.value.trim()
if (trimmed !== event.target.value) update(index, 'id', trimmed)
}}
/>
<input
className={styles['input']}
type="text"
value={typeof model['name'] === 'string' ? model['name'] : ''}
placeholder={props.t('modelName')}
aria-label={`${props.t('modelName')} ${String(index + 1)}`}
disabled={props.disabled}
onChange={(event) => {
update(index, 'name', event.target.value === '' ? undefined : event.target.value)
}}
/>
<button
type="button"
className={styles['iconButton']}
aria-label={`${props.t('modelAdvanced')} ${String(index + 1)}`}
aria-expanded={expanded.has(index)}
title={props.t('modelAdvanced')}
onClick={() => { toggle(index) }}
>
{expanded.has(index) ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</button>
<button
type="button"
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
aria-label={`${props.t('removeModel')} ${String(index + 1)}`}
title={props.t('removeModel')}
disabled={props.disabled}
onClick={() => { remove(index) }}
>
<IconTrashOutline16 size={14} />
</button>
</div>
{expanded.has(index)
? (
<div className={styles['modelAdvanced']}>
{capacityField(model, index, 'contextWindow', props.defaultContextWindow)}
{capacityField(model, index, 'maxTokens', props.defaultMaxTokens)}
</div>
)
: null}
</div>
))}
</div>
)}
<button
type="button"
className={styles['addModelButton']}
disabled={props.disabled}
onClick={() => { props.onChange([...props.models.map(model => ({ ...model })), { id: '' }]) }}
>
<IconPlusOutline16 size={14} />
{props.t('addModel')}
</button>
</section>
)
}

View File

@@ -0,0 +1,99 @@
.page {
position: relative;
z-index: 1;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(104px, 18vh, 156px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
color: var(--dsw-alias-label-primary);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.02em;
outline: none;
}
.description {
margin: 16px 0 0;
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.primary {
min-width: 132px;
}
.brand,
.title,
.description,
.actions {
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.title { animation-delay: 40ms; }
.description { animation-delay: 80ms; }
.actions { animation-delay: 120ms; }
@keyframes credential-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.description,
.actions {
animation: none;
}
}
@media (max-width: 560px) {
.page {
width: calc(100vw - 40px);
padding-top: 64px;
}
.brand {
margin-bottom: 30px;
}
.actions {
align-items: stretch;
flex-direction: column-reverse;
margin-top: 32px;
}
.primary,
.later {
width: 100%;
}
}

View File

@@ -0,0 +1,111 @@
/**
* Official-DeepSeek first-run step. Readiness comes from the same
* provider/settings/credential join as the Models page: any provider the user
* can already talk to ends the step, and only a user with none is offered the
* official DeepSeek route. The prompt itself only routes to that page's single
* credential editor.
*/
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { onboardingReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
/** Injected dependencies of {@link DeepSeekOnboardingDialog}. */
export interface DeepSeekOnboardingInjected {
/** Shared Models-page join controller. */
controller: ModelsSettingsStore
/** Subscription hook bound to the shared join snapshot. */
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
/** Feature copy. */
t: (key: keyof typeof en) => string
}
/** Slot owner props plus the feature's injected dependencies. */
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected DeepSeek onboarding state')
}
/**
* Prompt a first-run user to open Models while no provider can serve requests
* and the official adapter exists with an unconfigured effective credential.
* @param props - settings-shell owner state and Models feature dependencies.
* @returns the onboarding page or null when onboarding needs no intervention.
*/
export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode {
const { complete, openSection, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const readiness = onboardingReadiness(state)
const titleRef = useRef<HTMLHeadingElement | null>(null)
useEffect(() => {
if (state.status === 'idle') void controller.load()
}, [controller, state.status])
useEffect(() => {
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'provider-ready'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
useEffect(() => {
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
}, [readiness.kind])
const openModels = (): void => {
complete()
openSection('models')
}
// Null covers the still-deciding and nothing-to-do states alike: the
// takeover chrome below is part of THIS render, so declining paints and
// blocks nothing while the shared join is in flight.
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'provider-ready':
case 'unavailable':
return null
case 'credential-missing':
break
/* v8 ignore next -- every current readiness variant is handled above */
default:
return assertNever(readiness)
}
return (
<OnboardingSurface>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>
</section>
</OnboardingSurface>
)
}

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-settings-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,468 @@
/**
* 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-api-remotes/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
/**
* Copy key naming why the fetch action is unavailable, or `undefined` when
* it is. The card owns this because the key it would send is judged there:
* asking with a key the form has already refused spends a round trip to be
* told what the field already says.
*/
probeBlocked?: keyof typeof en | undefined
/** 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 || props.probeBlocked !== undefined}
title={props.probeBlocked !== undefined
? t(props.probeBlocked)
: 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

@@ -0,0 +1,675 @@
/* Models settings section, in the settings-panel design language: 14/22 body,
* 12/18 caption, capsule controls (h36 r18; h28 r14 where a row is dense),
* 32px fields, and `border-l2` hairlines — the vocabulary GeneralSection and
* the Button/Input primitives already use.
*
* Every color resolves through a `--dsw-alias-*` token. Bare `--border` /
* `--surface` / `--text-*` names, which nothing in this app defines, would
* render the light-mode literals written as their fallbacks and stay light
* under the dark theme. */
.section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 720px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.intro {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-tertiary);
}
.notice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-warn-label);
}
.savedNotice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-success-primary);
}
.rows {
list-style: none;
/* Extra air between the title/intro block and the first provider card. */
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
/* A configured provider: outlined on the panel fill, so the filled editor
card it expands into reads as the nested object. */
.rowCard {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.rowHead {
display: flex;
align-items: center;
gap: 10px;
}
.rowIdentity {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rowName {
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Reads as an annotation on the name, not as a second name: caption size and
the secondary label tone, so it never competes with the row's own title. It
sits inside `rowIdentity` with the credential dot, which is what keeps it
beside the name rather than drifting toward the actions. */
.rowTag {
flex: none;
padding: 1px 6px;
border: 1px solid var(--dsw-alias-border-l3);
border-radius: 4px;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
}
.credentialDot {
box-sizing: border-box;
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.credentialDotConfigured {
background: var(--dsw-alias-state-success-primary);
}
.credentialDotMissing {
background: var(--dsw-alias-state-error-primary);
}
.rowActions {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
/* `box-sizing` on every control here: the app has no global border-box reset,
so without it the outlined variants stand 2px taller than the filled ones
they sit beside (Cancel next to Apply, Edit next to Delete). */
.primaryButton,
.secondaryButton,
.addButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.primaryButton {
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-foreground);
}
.primaryButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.secondaryButton,
.addButton {
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
color: var(--dsw-alias-label-primary);
}
.secondaryButton:hover:not(:disabled),
.addButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.secondaryButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.dangerButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: transparent;
color: var(--dsw-alias-state-error-primary);
font: inherit;
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.dangerButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Provider-row controls take the dense capsule (Button `.sm`). */
.rowActions .secondaryButton,
.rowActions .dangerButton {
height: 28px;
padding: 0 10px;
border-radius: 14px;
font-size: 12px;
line-height: 18px;
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled,
.addButton:disabled,
.linkButton:disabled,
.addModelButton:disabled {
opacity: 0.4;
cursor: default;
}
.primaryButton:focus-visible,
.secondaryButton:focus-visible,
.dangerButton:focus-visible,
.addButton:focus-visible,
.linkButton:focus-visible,
.addModelButton:focus-visible,
.iconButton:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Editing surface: a filled module on the panel, matching the settings
selector fill rather than adding another outline inside the row. */
.editor {
border-radius: 12px;
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.editorHeader {
display: flex;
align-items: baseline;
gap: 8px;
}
.editorTitle {
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.editorRoute {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.linkButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.linkButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.advancedHint {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.editorActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.addBlock {
display: flex;
flex-direction: column;
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 {
/* 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;
height: 44px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
}
.addCard,
.setupCard {
border-radius: 12px;
background: var(--dsw-alias-bg-module-platform);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 14px;
list-style: none;
}
/* Nested in a card that already carries the module chrome. */
.addCard .editor,
.setupCard .editor {
background: none;
padding: 0;
}
.customized {
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 10px;
}
/* Native disclosure marker replaced by a rotating chevron: the built-in
triangle differs per engine and cannot take the label color. */
.customizedSummary {
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 2px 4px;
margin-left: -4px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
list-style: none;
}
.customizedSummary::-webkit-details-marker {
display: none;
}
.customizedSummary::before {
content: '';
width: 5px;
height: 5px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
transform: rotate(-45deg) translate(-1px, -1px);
transition: transform 120ms ease;
}
.customized[open] > .customizedSummary::before {
transform: rotate(45deg) translate(-1px, -1px);
}
.customizedSummary:hover {
color: var(--dsw-alias-label-primary);
}
.customizedBody {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
}
/* Model catalog: a table, not a stack of cards. The column captions are
written once above the rows, so a row is one line of fields plus its
delete control; each field still carries the indexed `aria-label` that
names it, and the caption strip is hidden from assistive tech to keep
that name from being announced twice. */
.modelCatalog {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.modelCatalogHeading {
display: flex;
flex-direction: column;
gap: 2px;
}
.modelCatalogTitle {
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.modelCatalogMeta,
.modelEmpty {
margin: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
/* Model list, shared with the pi-ai provider form: one bordered
entry per model, id and display name on the row, capacities behind the
row's own disclosure. The rules use this stylesheet's token vocabulary —
`--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and
`--dsw-alias-text-primary` are undefined here and would resolve to their
light-mode literals. */
.modelList {
display: flex;
flex-direction: column;
gap: 8px;
}
.modelListHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.modelEntry {
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px;
}
.modelRow {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
}
/* Square, label-free affordances: the row's own inputs carry the meaning, so
the actions stay glyphs and announce themselves through aria-label. */
.iconButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
cursor: default;
opacity: 0.4;
}
/* The delete glyph keeps the danger tint the rest of the section uses. */
.iconButtonDanger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.modelAdvanced {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
padding: 8px 4px 2px;
}
.modelField {
display: flex;
flex-direction: column;
gap: 4px;
}
.modelFieldLabel {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
}
.modelEmpty {
padding: 12px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 8px;
text-align: center;
}
.addModelButton {
box-sizing: border-box;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 14px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
font-size: 12px;
line-height: 18px;
cursor: pointer;
}
.addModelButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.input {
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
font: inherit;
font-size: 14px;
line-height: 22px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
}
/* Enum pickers hold a handful of short options; a field-width dropdown reads
as a text field the user is expected to fill. */
select.input {
max-width: 240px;
cursor: pointer;
}
.input:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
.input::placeholder {
color: var(--dsw-alias-label-dimmed);
}
.input:disabled {
opacity: 0.6;
cursor: default;
}
/* Select variant of .input: replaces the OS arrow (which sits flush against
the right edge) with the shared 12px chevron inset like the composer's
.select chips; the right pad reserves its cell. */
.selectInput {
appearance: none;
padding-right: 32px;
/* Data-URI SVGs cannot resolve CSS variables; #81858C is the caption gray
shared by both themes. */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px 12px;
}
.error {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
.deleteDialog {
width: min(480px, 100%);
}
.deleteConfirm:not(:disabled) {
border-color: var(--dsw-alias-state-error-primary);
color: var(--dsw-alias-state-error-primary);
}
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Icon-button label seat: named for assistive tech and for the tests that
query these controls by their text. */
.hiddenLabel {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.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(--ds-font-family-code);
font-size: 13px;
overflow-wrap: anywhere;
}

View File

@@ -0,0 +1,525 @@
/**
* Models settings section: the provider rows joined from the configurable
* directory, settings namespaces, and credential states, with one editor
* card at a time. Rows expose only confirmed API-key state through accessible
* solid configured or missing dots. A whole-section provider without a
* configured key renders as its open setup card instead of a row, but only in
* the first-run posture — no provider on the page can serve requests yet — and
* only until the user closes that card; the add flow is a card carrying the
* dormant-provider select. Each card kind owns its own open state, so closing
* one never discards a draft in another. Every mutation writes through the
* wire, while a provider removal first requires confirmation; the page
* re-renders from pushed invalidations or the post-apply reload.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor, type ProviderEditorProps } 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 writes through. */
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
/** Section copy. */
t: (key: keyof typeof en) => string
}
/**
* Props delivered by the slot outlet: the inject face spread flat (the
* renderer erases the share boundary at the render call).
*/
export type ModelsSectionProps = Partial<ModelsSectionInjected>
/** Provider identity shared by row actions and confirmation copy. */
export interface ProviderIdentity {
/** Stable provider route id. */
provider: string
/** Human-facing provider name. */
displayName: string
}
/** One existing row or dormant directory entry addressed by an editor action. */
interface EditorTarget extends ProviderIdentity {
settingsNs: string
settingsPath: readonly string[]
/** Writable credential identified under this page's conventional reference. */
credentialRef?: string
/** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */
declared?: boolean
}
/** Values that vary around the shared provider-editor rendering. */
interface ProviderEditorRenderProps extends Pick<
ProviderEditorProps,
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
> {
target: EditorTarget
}
/** Render an editor for either the setup posture or an expanded provider row. */
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
return (
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
settingsPath={target.settingsPath}
{...target.declared === true ? { declared: true } : {}}
{...props}
/>
)
}
/**
* Remove one user-added provider and its page-managed credential. Credential
* removal comes first so a second-step failure leaves the provider row visible
* and the whole operation safely retryable; both unsets are idempotent.
* The settings removal names the profile rather than rebuilding its whole
* namespace from a partial view.
* @param api - settings and credential wire faces.
* @param controller - the page store to refresh.
* @param target - the provider's settings address and optional managed credential.
* @returns the failure message, or undefined once the write and reload landed.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings' | 'credentials'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
): Promise<string | undefined> {
try {
if (target.credentialRef !== undefined) {
const credential = await api.credentials.unset({ ref: target.credentialRef })
if (!credential.result.ok) return credential.result.error.message
}
const response = await api.settings.mutate({
ns: target.settingsNs,
ops: [{ op: 'unset', path: [...target.settingsPath] }],
})
if (!response.result.ok) return response.result.error.message
} catch (error) {
// The transport rejected rather than answering; the caller must be able
// to retry the idempotent operation instead of the row silently staying.
return messageOf(error)
}
await controller.load()
return undefined
}
/**
* Whether a whole-section provider still needs its first key: an unconfigured
* credential opens the setup card instead of showing a row. This is the
* first-run posture alone — a user who can already reach some provider gets an
* ordinary row with the missing-key dot, since nothing here is blocking them.
* @param row - the joined provider row.
* @param anyUsable - whether any joined row can already serve requests.
* @returns whether to render the setup card.
*/
export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean {
if (anyUsable) return false
if (row.entry.settingsPath.length > 0) return false
return row.credential?.configured !== true
}
function targetOf(row: ProviderRow): EditorTarget {
const managedRef = deriveKeyRef(row.entry.provider)
const credentialRef = row.apiKeyEnv === managedRef
&& row.credential?.configured === true
&& row.credential.writable
? managedRef
: undefined
return {
provider: row.entry.provider,
displayName: row.entry.displayName,
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
...credentialRef === undefined ? {} : { credentialRef },
// Absent is not "shipped": an adapter that answers nothing leaves the
// route-level fields only a declared route owns off the card, exactly as
// it leaves the custom tag off the row.
...row.entry.declared === true ? { declared: true } : {},
}
}
/** Stable visible and accessible identity for one provider target. */
export function providerTargetLabel(target: ProviderIdentity): string {
return target.provider === target.displayName
? target.provider
: `${target.displayName} (${target.provider})`
}
/** Replace the one provider placeholder in localized destructive-action copy. */
export function providerCopy(template: string, target: ProviderIdentity): string {
return template.replace('{provider}', () => providerTargetLabel(target))
}
/**
* 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 { controller, useSnapshot, api, t } = props
if (controller === undefined || useSnapshot === undefined || api === undefined || t === undefined) return null
return <Loaded injected={{ controller, useSnapshot, api, t }} />
}
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 [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
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 [dismissedSetup, setDismissedSetup] = useState<ReadonlySet<string>>(() => new Set())
const announceSaved = (target: ProviderIdentity): void => {
// Announced only once the refreshed directory is in the snapshot the
// notice reads its name from: an apply can rename the route, and the
// target captured when the card opened still carries the old name.
void controller.load().then(() => { setSavedTarget(target) })
}
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) announceSaved(target)
}
/**
* Close a setup card, which owns none of the state above: the row-editor,
* add, and declare cards each own one of those, so clearing them here would
* discard a draft the user opened beside this card. Dismissal is this card's
* own — the provider falls back to an ordinary row for the rest of the
* session, and reopens through Edit.
*/
const closeSetup = (changed: boolean, target: ProviderIdentity): void => {
setDismissedSetup(previous => new Set([...previous, target.provider]))
if (changed) announceSaved(target)
}
const closeDelete = (): void => {
if (deleting) return
setDeleteTarget(undefined)
setDeleteFailure(undefined)
}
const confirmDelete = (): void => {
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
if (deleteTarget === undefined || deleting) return
setDeleting(true)
setDeleteFailure(undefined)
void removeProviderProfile(api, controller, deleteTarget)
.then((failure) => {
if (failure !== undefined) {
setDeleteFailure(failure)
return
}
setDeleteTarget(undefined)
})
.finally(() => { setDeleting(false) })
}
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>
)
}
// The saved provider as the directory currently names it. The route id is
// what the apply cannot change, so it is what the notice is keyed by; a row
// the same apply removed keeps the captured identity, since nothing newer
// exists to name it with.
const savedRow = savedTarget === undefined
? undefined
: state.rows.find(row => row.entry.provider === savedTarget.provider)
const savedIdentity = savedRow === undefined
? savedTarget
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
// One fact decides both first-run postures on this page and the onboarding
// step: whether the user already has a provider to talk to.
const anyUsable = state.rows.some(providerUsable)
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)
// 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']}>
<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}
{savedIdentity === undefined
? null
: (
<p className={styles['savedNotice']} role="status" aria-live="polite">
{providerCopy(t('savedProvider'), savedIdentity)}
</p>
)}
<ul className={styles['rows']}>
{configured.map((row) => {
const target = targetOf(row)
const namespace = state.namespaces.get(target.settingsNs)
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
if (namespace === undefined) return null
if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
// First-run posture: the provider exists but has no key — the
// setup card IS its presence on the page, until the user closes it.
return (
<li key={row.entry.provider} className={styles['setupCard']}>
{renderProviderEditor({
target,
namespace,
api,
t,
readOnly: !state.writable,
onClose: (changed) => { closeSetup(changed, target) },
})}
</li>
)
}
const open = !adding && editing?.provider === row.entry.provider
const credentialConfigured = row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
&& row.credential?.configured === false
return (
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowIdentity']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
{/* Only the adapter can tell a hand-declared route from a
shipped one it also has a stored profile for, so the tag
follows its answer and stays off when it gives none. */}
{row.entry.declared === true
? <span className={styles['rowTag']}>{t('customTag')}</span>
: null}
{credentialConfigured
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
role="img"
aria-label={t('credentialConfigured')}
title={t('credentialConfigured')}
/>
)
: credentialMissing
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
role="img"
aria-label={t('credentialMissing')}
title={t('credentialMissing')}
/>
)
: null}
</span>
<span className={styles['rowActions']}>
<button
type="button"
className={styles['secondaryButton']}
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)
}}
>
{t('edit')}
</button>
{row.removable
? (
<button
type="button"
className={styles['dangerButton']}
aria-label={providerCopy(t('removeProvider'), target)}
disabled={!state.writable}
onClick={() => {
setSavedTarget(undefined)
setDeleteFailure(undefined)
setDeleteTarget(target)
}}
>
{t('remove')}
</button>
)
: null}
</span>
</div>
{open
? renderProviderEditor({
target,
namespace,
api,
t,
readOnly: !state.writable,
onClose: (changed) => { closeEditor(changed, target) },
})
: null}
</li>
)
})}
</ul>
<div className={styles['addBlock']}>
{addTarget !== undefined && addNamespace !== undefined
? (
<div className={styles['addCard']}>
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('provider')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={addTarget.provider}
aria-label={t('provider')}
onChange={(event) => {
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
/* v8 ignore next -- the select only lists addable rows */
if (row === undefined) return
setEditing(targetOf(row))
}}
>
{addable.map(row => (
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
))}
</select>
</div>
<ProviderEditor
key={addTarget.provider}
provider={addTarget.provider}
displayName={addTarget.displayName}
hideTitle
namespace={addNamespace}
settingsPath={addTarget.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={(changed) => { closeEditor(changed, addTarget) }}
/>
</div>
)
: 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}
onClose={closeDelete}
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
closeLabel={t('close')}
description={deleteTarget === undefined
? ''
: providerCopy(
deleteTarget.credentialRef === undefined
? t('deleteDescription')
: t('deleteDescriptionWithCredential'),
deleteTarget,
)}
className={styles['deleteDialog'] as string}
footer={(
<>
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
{t('cancel')}
</Button>
<Button
variant="outline"
className={styles['deleteConfirm']}
disabled={deleting}
onClick={confirmDelete}
>
{deleteTarget === undefined
? ''
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
</Button>
</>
)}
>
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,483 @@
/**
* One provider's editor card, hand-written per adapter family: the primary
* field is a single write-only **API key** input (the page never asks for an
* environment-variable name — a typed key stores through `credentials.set`
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
* a key is entered; a blank key materializes a reference-free profile for
* provider-native authentication);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, DeepSeek's id/name/context-window model catalog, and the
* display name and wire protocol of a pi-ai route the adapter does not ship —
* the two fields the create card asked that route for, editable here for the
* same reason).
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
* the models under one provider disagree about it, so a provider-scoped
* control can only be set to a value some of them reject. The composer's
* model picker offers each model its own levels; `settings.yaml` keeps the
* profile field for a deployment that knows its route. Everything else stays
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
* path ops against the stored section — the card names only the fields it can
* see instead of rebuilding the whole subtree from a partial descriptor.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
import {
deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
} from '@deepseek-ai/dsh-client-schema-form'
import {
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
} from './DeepSeekModelsEditor.tsx'
import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Props of {@link ProviderEditor}. */
export interface ProviderEditorProps {
/** Provider route id. */
provider: string
/** Display name for the card title. */
displayName: string
/** Hide the title row (the add card renders its own provider select). */
hideTitle?: boolean
/**
* Whether the adapter reports this route as hand-declared — absent from its
* installed catalog. Such a route carries its own wire protocol, chosen when
* it was created and editable here for the same reason; a catalog route's
* models each carry theirs, so a route-level protocol there could only
* override every one of them and the card does not offer it.
*/
declared?: boolean
/** 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 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). */
readOnly: boolean
/** Close the editor; `changed` reports whether an Apply committed. */
onClose: (changed: boolean) => void
}
/** 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>
}
/**
* The minimal path ops carrying `after` over `before`, both as the card sees
* them. Only keys the card observed are named; fields absent from both sides
* produce no op, which is why edits are path-addressed rather than a rebuilt
* section.
* @param base - path of the edited subtree inside the user section.
* @param before - the subtree as loaded, or undefined when it is new.
* @param after - the subtree as edited.
* @returns ordered set/unset ops; empty when nothing changed.
*/
export function pathOps(
base: readonly string[],
before: unknown,
after: Record<string, unknown>,
): SettingsPathOpView[] {
const previous = typeof before === 'object' && before !== null && !Array.isArray(before)
? before as Record<string, unknown>
: {}
const ops: SettingsPathOpView[] = []
for (const [key, value] of Object.entries(after)) {
if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue
ops.push({ op: 'set', path: [...base, key], value })
}
for (const key of Object.keys(previous)) {
if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] })
}
return ops
}
/** The editor layout the owning namespace selects. */
function layoutOf(ns: string): EditorLayout {
if (ns === 'llm-deepseek') return 'deepseek'
if (ns === 'llm-pi-ai') return 'pi-ai'
return 'unknown'
}
/** The credential reference this profile resolves keys through. */
function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string {
const profile = getPath(namespace.value, path)
const named = typeof profile === 'object' && profile !== null
? (profile as { apiKeyEnv?: unknown }).apiKeyEnv
: undefined
return typeof named === 'string' && named.length > 0 ? named : deriveKeyRef(provider)
}
/**
* Render one provider's editing card.
* @param props - the addressed profile plus wire faces and copy.
* @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 [keyDraft, setKeyDraft] = useState('')
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// A settings success advances both retry baselines immediately. Keeping the
// derived fields in the draft prevents a pushed namespace refresh from
// turning them into deletions when the following credential write is retried.
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
() => getPath(namespace.user, settingsPath),
)
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
const fallback = getPath(namespace.value, settingsPath)
const disabled = props.readOnly || busy
const layout = layoutOf(namespace.ns)
const keyRef = refFor(namespace, settingsPath, props.provider)
// The same schema read the create card makes, so the choices offered here
// and there cannot drift apart: both come from the adapter's own `Config`.
// Only the pi-ai layout has a per-route protocol for the read to find, and
// it rehydrates the whole section schema, so the other layouts skip it.
const protocols = useMemo(
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
[layout, namespace],
)
useEffect(() => {
let stale = false
setKeyState(undefined)
// The key state is a placeholder hint, not a precondition for editing:
// neither a business rejection nor a transport failure may reach the
// browser as an unhandled rejection, so the card simply renders without
// the "already configured" hint.
void api.credentials.describe({ refs: [keyRef] }).then(
(response) => {
if (stale || !response.result.ok) return
setKeyState(response.result.value.credentials[keyRef])
},
() => undefined,
)
return () => { stale = true }
}, [api.credentials, keyRef])
const stringAt = (source: unknown, key: string): string | undefined => {
const value = getPath(source, [key])
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
const setField = (key: string, next: string | undefined): void => {
// A value of nothing but whitespace is cleared, not stored: `stringAt`
// already reports it as absent, so the field would otherwise render empty
// while the draft still carried the spaces into `settings.yaml`, where
// both adapters would accept that non-empty string as a real value.
const value = next === undefined || next.trim().length === 0 ? undefined : next
setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
}
// 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']))
const keyFailure = apiKeyFailure(keyDraft)
// What a probe or a write must carry: the typed key with paste whitespace
// removed. A blank field yields an empty string, which both call sites read
// as "no key supplied" rather than as a key — that is how a card whose
// provider already has a stored key is edited without re-entering it.
const keyValue = keyDraft.trim()
// 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 },
...keyValue.length === 0 ? {} : { apiKey: keyValue },
}
/**
* 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
* descriptor, so a wholesale replace rebuilt from it could delete fields
* outside the card. Ops name only the fields this card can see.
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns
// A pi-ai profile names the conventional reference only when this page is
// about to store a key. Otherwise the provider keeps its native auth path.
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
{
// 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 */
if (node !== undefined && settingsPath.length === 0) {
const sectionError = validateDraft(node, next)
if (sectionError !== undefined) return sectionError
}
const materializesNativeProfile = layout === 'pi-ai'
&& fallback === undefined
&& committedOriginal === undefined
&& Object.keys(next).length === 0
const ops: SettingsPathOpView[] = materializesNativeProfile
? [{ op: 'set', path: [...settingsPath], value: {} }]
: pathOps(settingsPath, committedOriginal, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops, expectedRevision })
if (!response.result.ok) {
return response.result.error.code === 'settings-conflict'
? t('conflict')
: response.result.error.message
}
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
setExpectedRevision(response.result.value.revision)
setDraft(next)
}
if (keyValue.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
if (!stored.result.ok) return stored.result.error.message
}
setKeyDraft('')
return undefined
}
const apply = async (): Promise<void> => {
setBusy(true)
setFailure(undefined)
try {
const failure = await applyOnce()
if (failure !== undefined) {
setFailure(failure)
return
}
props.onClose(true)
} catch (error) {
// A transport failure (disconnect, a request the host refuses) rejects
// rather than answering; without this the card would stay busy forever
// with no error shown.
setFailure(messageOf(error))
} finally {
setBusy(false)
}
}
if (node === undefined) {
// A directory entry addressing a position its schema cannot resolve is a
// host-side inconsistency; showing it beats a blank card.
return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
}
const keyLocked = keyState?.writable === false
/**
* The catalog beneath the user layer: what the composition entry pinned, or
* else the schema default that `resolve` would supply. The effective value
* cannot answer this — it still carries the stored override until the unset
* is applied, so reading it would echo that override straight back the
* moment reset drops it, leaving the rows unchanged until a reload.
*/
const inheritedModels = (): unknown => {
const pinned = getPath(namespace.base, [...settingsPath, 'models'])
return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default
}
/**
* The curated fields of one known adapter family. The family arrives
* narrowed so the per-family branches below are total: an unknown namespace
* renders the hint instead and never reaches this body.
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
// What a hand-declared route names for itself and nothing else can supply.
// A whole-section `llm-deepseek` profile is a composition fact with no
// per-route identity for its schema to carry, hence the family test.
const ownsIdentity = family === 'pi-ai' && props.declared === true
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
const keyPlaceholder = keyLocked
? t('keyEnvLocked')
: 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']}>
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
<input
className={styles['input']}
type="password"
autoComplete="off"
value={keyDraft}
placeholder={keyPlaceholder}
aria-label={t('keyInput')}
disabled={disabled || keyLocked}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
</div>
<details className={styles['customized']}>
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
<div className={styles['customizedBody']}>
{/* The name and the protocol are the create card's two remaining
profile fields; a route the adapter ships defaults both from
its catalog entry and neither belongs on its card. */}
{ownsIdentity
? (
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
className={styles['input']}
type="text"
value={stringAt(draft, 'displayName') ?? ''}
// What this route is called the moment the field is
// cleared, which is the layer beneath the one this field
// edits: a `cordis.yml` may pin a name for a route the
// catalog does not ship, and only when nothing does is
// the answer the route id. Reading the effective value
// instead would echo the stored override back as the
// thing clearing restores.
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
?? props.provider}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setField('displayName', event.target.value) }}
/>
</div>
)
: null}
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
className={styles['input']}
type="text"
value={stringAt(draft, 'baseURL') ?? ''}
placeholder={family === 'deepseek'
? DEEPSEEK_PUBLIC_BASE_URL
: stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
aria-label={t('baseUrl')}
disabled={disabled}
onChange={(event) => {
setField('baseURL', event.target.value === '' ? undefined : event.target.value)
}}
/>
</div>
{/* The protocol sits beside the endpoint it describes, as it does
on the create card. */}
{ownsIdentity
? (
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={probeApi ?? ''}
aria-label={t('customApi')}
disabled={disabled}
onChange={(event) => { setField('api', event.target.value) }}
>
{/* A profile naming no protocol — hand-written into
settings.yaml with no model to need one — selects
nothing rather than reading as if it had picked the
first choice. The option is named because a screen
reader announces it either way, and an empty one is
announced as a choice with no identity. */}
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>
)
: null}
{/* 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
{...catalogProps}
defaultContextWindow={typeof defaultContextWindow === 'number'
? defaultContextWindow
: undefined}
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
/>
)
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
</div>
</details>
</>
)
}
return (
<div className={styles['editor']}>
{props.hideTitle === true
? null
: (
<div className={styles['editorHeader']}>
<span className={styles['editorTitle']}>{props.displayName}</span>
{props.provider !== props.displayName
? <span className={styles['editorRoute']}>{props.provider}</span>
: null}
</div>
)}
{layout === 'unknown'
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
: curatedFields(layout)}
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{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
|| keyFailure !== undefined}
submitLabel="apply"
submitBusyLabel="applying"
onCancel={() => { props.onClose(false) }}
onSubmit={() => { void apply() }}
/>
</div>
)
}

View File

@@ -0,0 +1,58 @@
/**
* Browser-side judgement of a typed API key.
* @module @deepseek-ai/dsh-client-ui-settings-models/apiKey
*/
/**
* Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space
* excluded. Client packages reference only client packages, so the charset
* rule is mirrored here rather than imported; keep the two in step, as
* `validateDeepSeekModels` is kept in step with the host's `catalogModel`.
*/
const LEGAL_API_KEY = /^[\x21-\x7E]+$/
/**
* A pasted `NAME=value` environment line. Two narrowings keep real keys clear
* of it: the name must be upper-case, so `sk-` forms break at the hyphen, and
* the `=` must be followed by something other than another `=`, so base64
* padding on an all-upper-case key (`ABCD==`) is not mistaken for an
* assignment. This heuristic runs only here — a resolver applying it could
* lock a user out of a gateway whose key legitimately takes this shape, with
* the environment refusing it too and no way through.
*/
const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
/**
* Copy key naming why a typed key cannot be saved. A wrapped paste reports the
* same format failure as an illegal character: the reader's next move is the
* same either way — look at the key and paste it again — so naming the two
* causes apart would spend the field's one line on a distinction that changes
* nothing about what to do.
*/
export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters'
/** Whether a value is wrapped in one matching pair of quotes. */
function isQuoted(value: string): boolean {
const first = value[0]
if (first !== '"' && first !== '\'' && first !== '`') return false
return value.length > 1 && value.endsWith(first)
}
/**
* Judge the key input's current value.
*
* An empty field is not a failure: every card opens with it empty even when a
* key is already stored, where it means keep that one. A field holding only
* whitespace is a failure rather than an empty field, so typed input is never
* silently discarded.
* @param draft - the key input's current value, untrimmed.
* @returns the copy key for a field-level failure, or `undefined` to allow submit.
*/
export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined {
if (draft.length === 0) return undefined
const value = draft.trim()
if (value.length === 0) return 'keyBlank'
if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
return undefined
}

View File

@@ -0,0 +1,109 @@
/**
* Models settings plugin, browser half. Registers the `models` nav entry and
* official-DeepSeek first-run overlay into shell-declared slots. Both consume
* one provider/settings/credential join; the overlay routes missing-key users
* to the full page's single credential editor. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/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'
// Type-only: pulls the ctx.remote merge and the forwarded-event key face
// (settings/credentials invalidations ride the allowlist) into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { ModelsSection } from './ModelsSection.tsx'
import type { ModelsSectionInjected } from './ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx'
import type { DeepSeekOnboardingInjected } from './DeepSeekOnboardingDialog.tsx'
import { ModelsSettingsStore } from './store.ts'
import { en, zh, type ModelsKey } from './locales.ts'
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
export type { ModelsKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The Models page + onboarding overlay copy. */
'settings.models': ModelsKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.models'
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 depends on each slot through `slots.inject()`.
*/
export const inject = ['slots', 'locale', 'connection', 'remote']
/**
* Register the Models section once the `settings.section` declaration is on
* 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(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new ModelsSettingsStore(connection.api)
const useSnapshot = bindSnapshotSelector(controller.store)
// Registration-time text (the nav label thunk) and the inject faces share
// one bound translate; copy freshness rides the locale revision.
const t = ctx.locale.bind(NS) as ModelsSectionInjected['t']
const injected = (): ModelsSectionInjected => ({
controller,
useSnapshot,
api: connection.api,
t,
})
const onboardingInjected = (): DeepSeekOnboardingInjected => ({
controller,
useSnapshot,
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.remote.$on('settings/document-updated', refresh),
ctx.remote.$on('credentials/updated', refresh),
ctx.remote.$on('llm/adapters-updated', refresh),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-settings-models: pushed invalidations')
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'models',
order: 10,
label: () => t('nav'),
inject: injected,
}, ModelsSection))
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
order: 0,
inject: onboardingInjected,
}, DeepSeekOnboardingDialog))
}

View File

@@ -0,0 +1,186 @@
/** Copy dictionaries for the Models settings section. */
/** English strings (the key-set source of truth for this pair). */
export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
edit: 'Edit',
editProvider: 'Edit {provider}',
remove: 'Delete',
removeProvider: 'Delete {provider}',
deleteTitle: 'Delete {provider}?',
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
deleteConfirm: 'Delete {provider}',
deleting: 'Deleting {provider}…',
add: 'Add provider',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
savedProvider: 'Saved {provider}.',
credentialConfigured: 'API key configured',
credentialMissing: 'API key missing',
readOnly: 'The settings document is read-only in this deployment.',
loadFailed: 'Loading the provider directory failed',
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
retry: 'Retry',
keyInput: 'API key',
keyPlaceholder: 'Enter your API key',
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
keyStored: 'Configured — enter a new value to replace',
keyEnvLocked: 'Provided by the launch environment (read-only)',
customized: 'Customized settings',
baseUrl: 'Base URL',
baseUrlDefault: 'Provider default',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
resetModels: 'Restore defaults',
model: 'Model',
modelId: 'Model ID',
modelName: 'Display name',
modelNamePlaceholder: 'Uses the model ID when empty',
contextWindow: 'Context window',
contextWindowPlaceholder: 'Uses the provider default',
maxTokens: 'Max output tokens',
maxTokensPlaceholder: 'Uses the provider default',
modelAdvanced: 'Capacities',
addModel: 'Add model',
removeModel: 'Delete model',
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
keyIllegalCharacters: 'This API key is not in a valid format. Please check it.',
modelIdRequired: 'Model ID is required.',
modelIdDuplicate: 'Model ID must be unique.',
modelNameInvalid: 'Display name cannot be empty.',
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',
customTag: 'Custom',
customRoute: 'Provider ID',
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.',
customRouteTaken: 'A provider already uses this ID.',
customDisplayName: 'Display name',
customApi: 'API protocol',
customApiUnset: 'Not selected',
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',
onboardingLater: 'Configure later',
}
/** The settings.models namespace key union. */
export type ModelsKey = keyof typeof en
/** Chinese strings (same keys as {@link en}). */
export const zh: typeof en = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
edit: '编辑',
editProvider: '编辑 {provider}',
remove: '删除',
removeProvider: '删除 {provider}',
deleteTitle: '删除 {provider}?',
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
deleteConfirm: '删除 {provider}',
deleting: '正在删除 {provider}…',
add: '添加提供方',
provider: '提供方',
close: '关闭',
cancel: '取消',
apply: '保存',
applying: '保存中…',
savedProvider: '已保存 {provider}。',
credentialConfigured: 'API 密钥已配置',
credentialMissing: 'API 密钥缺失',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
retry: '重试',
keyInput: 'API 密钥',
keyPlaceholder: '输入 API 密钥',
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
keyStored: '已配置——输入新值可替换',
keyEnvLocked: '由启动环境提供(只读)',
customized: '自定义设置',
baseUrl: 'API 地址',
baseUrlDefault: '提供方默认',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
resetModels: '恢复默认模型',
model: '模型',
modelId: '模型 ID',
modelName: '显示名称',
modelNamePlaceholder: '留空时使用模型 ID',
contextWindow: '上下文窗口',
contextWindowPlaceholder: '使用提供方默认值',
maxTokens: '最大输出 token 数',
maxTokensPlaceholder: '使用提供方默认值',
modelAdvanced: '容量',
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',
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: '自定义提供方',
customTag: '自定义',
customRoute: 'Provider ID',
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
customRouteTaken: '已有提供方使用了这个 ID。',
customDisplayName: '显示名称',
customApi: 'API 协议',
customApiUnset: '未选择',
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
customNeedsModels: '自定义提供方至少需要一个模型。',
create: '创建提供方',
creating: '创建中\u2026',
onboardingTitle: '添加一个 API Key 开始使用',
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
}

View File

@@ -0,0 +1,277 @@
/**
* 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-api-remotes/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
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 {
/** 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
/** Credential enrichment failure; provider/settings rows remain usable. */
credentialError: 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>
}
/**
* Human text for a rejected wire call. A transport failure rejects with an
* Error; a host or a runtime can reject with anything, and the page still has
* to say something.
* @param error - the rejection value.
* @returns the message to show.
*/
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Derive the conventional credential reference for a provider route: the v1
* page never asks for an environment-variable name, so a typed key stores
* under this derived reference and the profile records it as `apiKeyEnv`.
* @param provider - provider route id (e.g. `anthropic`, `minimax-cn`).
* @returns the derived reference name (e.g. `MINIMAX_CN_API_KEY`).
*/
export function deriveKeyRef(provider: string): string {
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
}
/**
* The 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
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, credentialError: 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> = {}
let credentialError: string | null = null
if (refs.length > 0) {
try {
const response = await this.api.credentials.describe({ refs })
// Credential state is an enrichment for the Models page: neither a
// business rejection nor a transport failure fails the load. The
// onboarding projection below retains the failure distinction.
if (response.result.ok) credentials = response.result.value.credentials
else credentialError = response.result.error.message
} catch (error) {
credentialError = messageOf(error)
}
}
if (generation !== this.generation) return
this.store.update((s) => {
s.status = 'ready'
s.error = null
s.credentialError = credentialError
s.writable = writable
s.rows = rows.map(row => ({
...row,
...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
? { credential: credentials[row.apiKeyEnv] }
: {},
}))
s.namespaces = namespaces
})
}
}
/**
* Whether a joined row can serve model requests as it stands: the route is
* registered with the adapter registry, and whatever credential its resolved
* profile names is stored. A profile naming no reference authenticates through
* the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs
* nothing), as does a live route with no settings address at all, so neither
* owes this page a key.
* @param row - one joined provider row.
* @returns whether the user already has this provider to talk to.
*/
export function providerUsable(row: ProviderRow): boolean {
if (!row.entry.active) return false
if (row.apiKeyEnv === undefined) return true
return row.credential?.configured === true
}
/** First-run onboarding readiness derived only from the shared Models join. */
export type OnboardingReadiness =
| { kind: 'loading' }
| { kind: 'adapter-absent' }
| { kind: 'provider-ready' }
| { kind: 'credential-missing' }
| {
kind: 'unavailable'
reason:
| 'load-failed'
| 'provider-inactive'
| 'credentials-unavailable'
| 'settings-read-only'
| 'credential-read-only'
}
/**
* Project first-run readiness from the provider/settings/credential join used
* by the Models page. The step exists to leave the user with a model to talk
* to, so ANY usable provider ends it; only when none exists does the official
* DeepSeek route — the one route the prompt can offer a key field for — decide
* whether prompting can help. A missing official configurable-provider
* declaration means the adapter is not repairable by navigating to Models.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness {
if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) {
return { kind: 'loading' }
}
if (state.status === 'error') {
return {
kind: 'unavailable',
reason: 'load-failed',
}
}
if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
&& candidate.entry.settingsPath.length === 0)
if (row === undefined) return { kind: 'adapter-absent' }
if (!row.entry.active) {
return {
kind: 'unavailable',
reason: 'provider-inactive',
}
}
// Past the usable gate an active route names a reference it has no stored
// credential for, so the remaining questions are all about that credential.
if (state.credentialError !== null || row.credential === undefined) {
return {
kind: 'unavailable',
reason: 'credentials-unavailable',
}
}
if (!state.writable) {
return {
kind: 'unavailable',
reason: 'settings-read-only',
}
}
if (!row.credential.writable) {
return {
kind: 'unavailable',
reason: 'credential-read-only',
}
}
return { kind: 'credential-missing' }
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the models settings plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`.
* @module @deepseek-ai/dsh-client-ui-settings-models/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models'
/** Cordis companion plugin name. */
export const name = 'client-ui-settings-models-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a nav-entry-only section plugin rendering a fixed
* empty content column — it emits no cordis events and owns no cross-plugin
* mutable relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */