mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/provider-credential-lifecycle
# Conflicts: # packages/client/ui-models/README.i18n.yaml # packages/client/ui-models/README.md # packages/client/ui-models/README.zh.md # packages/client/ui-models/src/client/ModelsSection.tsx # packages/client/ui-models/src/client/ProviderEditor.tsx
This commit is contained in:
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal file
240
packages/client/ui-models/src/client/CustomProviderCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* The card that declares a provider pi-ai does not ship — an OpenAI-compatible
|
||||
* gateway, a self-hosted server, or a provider newer than the installed
|
||||
* catalog.
|
||||
*
|
||||
* This is a create, not an edit, which is why it is its own card rather than
|
||||
* the provider editor with extra fields: the route id is being *chosen* here,
|
||||
* and the settings address does not exist until it is. One `settings.mutate`
|
||||
* sets the whole profile at `providers.<route>`; the key travels separately
|
||||
* through `credentials.set` under the reference the profile records, exactly as
|
||||
* an existing provider's key does.
|
||||
*
|
||||
* The three fields a hand-declared route cannot default — endpoint, protocol,
|
||||
* and at least one model — are required here rather than at load, so the
|
||||
* failure names the field while the user is still looking at it.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import type { ModelDraft } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** The settings namespace a hand-declared provider is written into. */
|
||||
const NS = 'llm-pi-ai'
|
||||
|
||||
/** A route id usable as a settings key and as the stem of a credential name. */
|
||||
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
/** Props of {@link CustomProviderCard}. */
|
||||
export interface CustomProviderCardProps {
|
||||
/** Route ids already declared, so the card refuses to shadow one. */
|
||||
taken: readonly string[]
|
||||
/** Wire protocols the adapter can serve, in the order it reports them. */
|
||||
protocols: readonly string[]
|
||||
/**
|
||||
* Revision of the `llm-pi-ai` user section this card opened at, sent with
|
||||
* the create so a route another tab declared meanwhile is a refusal rather
|
||||
* than a silent overwrite of its whole profile.
|
||||
*/
|
||||
revision: number
|
||||
/** Wire faces for the write and for interrogating the endpoint. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Close the card; `changed` reports whether a provider was created. */
|
||||
onClose: (changed: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the custom-provider creation card.
|
||||
* @param props - existing routes, protocol choices, wire faces, and copy.
|
||||
* @returns the creation card.
|
||||
*/
|
||||
export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const { taken, protocols, api, t } = props
|
||||
// Captured at mount, like the editor's: the write must be judged against the
|
||||
// section this card was drafted over, not whatever it grew into meanwhile.
|
||||
const [openedAt] = useState(() => props.revision)
|
||||
const [route, setRoute] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [baseURL, setBaseURL] = useState('')
|
||||
const [protocol, setProtocol] = useState(protocols[0] ?? '')
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
const disabled = props.readOnly || busy
|
||||
|
||||
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
|
||||
const routeTaken = taken.includes(route)
|
||||
// Rows are checked by the same per-row validator the editor cards use, so a
|
||||
// bad row is named by its position here too. Capacities have route-level
|
||||
// fallbacks; what a route cannot default is at least one model.
|
||||
const modelFailure = validateDeepSeekModels(models)
|
||||
const ready = route.length > 0 && !routeInvalid && !routeTaken
|
||||
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
|
||||
// The one blocked gate worth a line under the form. The route id is omitted
|
||||
// because its own field already explains itself, and a satisfied card says
|
||||
// nothing at all rather than printing an empty paragraph.
|
||||
const hint = failure !== undefined || ready
|
||||
? undefined
|
||||
: baseURL.length === 0
|
||||
? t('customNeedsBaseUrl')
|
||||
: modelFailure !== undefined
|
||||
? `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
|
||||
: t('customNeedsModels')
|
||||
|
||||
/** Perform the create, returning a failure message or undefined. */
|
||||
const createOnce = async (): Promise<string | undefined> => {
|
||||
const keyRef = deriveKeyRef(route)
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
apiKeyEnv: keyRef,
|
||||
api: protocol,
|
||||
baseURL,
|
||||
models: models.map(model => ({ ...model })),
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: NS,
|
||||
ops: [{ op: 'set', path: ['providers', route], value: profile }],
|
||||
// `taken` is a snapshot too, so the id check alone cannot see a route
|
||||
// declared after this card opened; the revision makes that race a
|
||||
// `settings-conflict` instead of a write over the other profile.
|
||||
expectedRevision: openedAt,
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
// The profile landed; saying the key did not is the only honest report,
|
||||
// and the row is now editable so the key can be entered again there.
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const create = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
try {
|
||||
const outcome = await createOnce()
|
||||
if (outcome !== undefined) {
|
||||
setFailure(outcome)
|
||||
return
|
||||
}
|
||||
props.onClose(true)
|
||||
} catch (error) {
|
||||
// A transport failure rejects rather than answering; without this the
|
||||
// card would stay busy with nothing shown.
|
||||
setFailure(messageOf(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{t('customTitle')}</span>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customRoute')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={route}
|
||||
placeholder="acme-gateway"
|
||||
aria-label={t('customRoute')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setRoute(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<p className={styles['advancedHint']}>
|
||||
{routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
|
||||
</p>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={displayName}
|
||||
placeholder={route.length === 0 ? t('customDisplayName') : route}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setDisplayName(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={baseURL}
|
||||
placeholder="https://gateway.example/v1"
|
||||
aria-label={t('baseUrl')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setBaseURL(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setProtocol(event.target.value) }}
|
||||
>
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('keyInput')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<ModelListEditor
|
||||
models={models}
|
||||
onChange={setModels}
|
||||
probe={{
|
||||
settingsNs: NS,
|
||||
baseURL,
|
||||
api: protocol,
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
}}
|
||||
api={api}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
{/* Only the gates with something to say render; the route-id gate has its
|
||||
own field-level hint, so its blocked state would print an empty line. */}
|
||||
{hint === undefined ? null : <p className={styles['advancedHint']}>{hint}</p>}
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || !ready}
|
||||
submitLabel="create"
|
||||
submitBusyLabel="creating"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onSubmit={() => { void create() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal file
65
packages/client/ui-models/src/client/EditorFooter.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The action row every provider card ends with: dismiss on the left, commit on
|
||||
* the right.
|
||||
*
|
||||
* The two cards commit different things — one creates a route, one edits an
|
||||
* existing profile — but the row itself carries no such knowledge. It renders
|
||||
* what it is handed, so the cards keep sole ownership of when a commit is
|
||||
* allowed and what the in-flight wording is.
|
||||
*
|
||||
* Cancel refuses input only while a commit is in flight, never because the card
|
||||
* is disabled: a card the deployment cannot write to must still be dismissable.
|
||||
*
|
||||
* @module dsh-client-ui-models/client/EditorFooter
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props of {@link EditorFooter}. */
|
||||
export interface EditorFooterProps {
|
||||
/** Localizer for the row's own labels. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Whether a commit is in flight; holds Cancel and swaps the commit label. */
|
||||
busy: boolean
|
||||
/** Whether the commit is refused, as judged by the owning card. */
|
||||
submitDisabled: boolean
|
||||
/** Commit label while idle. */
|
||||
submitLabel: keyof typeof en
|
||||
/** Commit label while a commit is in flight. */
|
||||
submitBusyLabel: keyof typeof en
|
||||
/** Dismiss the card without committing. */
|
||||
onCancel: () => void
|
||||
/** Run the card's commit. */
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider card's action row.
|
||||
* @param props - the labels, commit gating, and handlers the owning card supplies.
|
||||
* @returns the cancel/commit row.
|
||||
*/
|
||||
export function EditorFooter(props: EditorFooterProps): ReactNode {
|
||||
const { t } = props
|
||||
return (
|
||||
<div className={styles['editorActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
disabled={props.busy}
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['primaryButton']}
|
||||
disabled={props.submitDisabled}
|
||||
onClick={props.onSubmit}
|
||||
>
|
||||
{props.busy ? t(props.submitBusyLabel) : t(props.submitLabel)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal file
459
packages/client/ui-models/src/client/ModelListEditor.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* The model list of one pi-ai provider profile, plus the action that asks the
|
||||
* provider what it serves.
|
||||
*
|
||||
* The list is the profile's `models` array as the card holds it: an empty list
|
||||
* means "serve this route's built-in catalog", and any entry replaces that
|
||||
* catalog, so a row is only ever added deliberately. Fetching asks the endpoint
|
||||
* **the form currently shows** — including a key typed but not yet saved — so
|
||||
* adding a provider is one pass instead of save-then-return; the reply is
|
||||
* candidates the user picks from, never configuration written behind them.
|
||||
*
|
||||
* A provider that cannot be interrogated (an unreachable endpoint, a protocol
|
||||
* with no readable listing) is not a dead end: the failure is shown next to the
|
||||
* rows the user can still fill in by hand.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx'
|
||||
import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx'
|
||||
import { messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/**
|
||||
* One configured model row. Structurally open, exactly like the DeepSeek
|
||||
* catalog editor's rows: a profile field this card does not edit — one a future
|
||||
* schema adds, or one hand-written in `settings.yaml` — has to survive being
|
||||
* edited here rather than being dropped by a rebuild.
|
||||
*/
|
||||
export type ModelDraft = DeepSeekModelDraft
|
||||
|
||||
/** A row's text field, or the empty string when unset or not a string. */
|
||||
function textOf(model: ModelDraft, key: string): string {
|
||||
const value = model[key]
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/** A row's numeric field, or `undefined` when unset or not a number. */
|
||||
function numberOf(model: ModelDraft, key: string): number | undefined {
|
||||
const value = model[key]
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
||||
/** What an interrogation needs, taken from the live form. */
|
||||
export interface ProbeTarget {
|
||||
/** Settings namespace whose adapter family answers. */
|
||||
settingsNs: string
|
||||
/**
|
||||
* Route being edited, when the card edits one. An adapter that already
|
||||
* describes it answers from its own registry, so such a card can ask without
|
||||
* an endpoint at all.
|
||||
*/
|
||||
provider?: string
|
||||
/** Endpoint as the form currently shows it. */
|
||||
baseURL?: string
|
||||
/** Wire protocol the form names, when it names one. */
|
||||
api?: string
|
||||
/** Key typed into the form and not yet stored, when there is one. */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
/** Props of {@link ModelListEditor}. */
|
||||
export interface ModelListEditorProps {
|
||||
/** The rows as currently drafted. */
|
||||
models: readonly ModelDraft[]
|
||||
/** Whether the user layer currently owns the whole array; absent on a create. */
|
||||
overridden?: boolean
|
||||
/** Replace the drafted rows. */
|
||||
onChange: (models: ModelDraft[]) => void
|
||||
/** Remove the user-owned array and return to inheritance; absent on a create. */
|
||||
onReset?: () => void
|
||||
/** Endpoint facts for the fetch action. */
|
||||
probe: ProbeTarget
|
||||
/** Wire face the fetch action calls. */
|
||||
api: Pick<IApiClient, 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable every control (read-only deployment or a pending write). */
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Disclosure chevron; rotates to point down while its row is open. */
|
||||
function IconChevron({ open }: { open: boolean }): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden
|
||||
style={{ transform: open ? 'rotate(90deg)' : undefined, transition: 'transform 120ms ease' }}
|
||||
>
|
||||
<path d="M6 3.5L10.5 8L6 12.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Removal glyph for one model row. */
|
||||
function IconTrash(): ReactNode {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.7 9a1 1 0 001 .9h4.6a1 1 0 001-.9L12 4M6.5 6.8v4.4M9.5 6.8v4.4"
|
||||
stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The two token counts edited as K/M-suffixed text behind a row's disclosure. */
|
||||
type CapacityField = 'contextWindow' | 'maxTokens'
|
||||
|
||||
/**
|
||||
* What an empty capacity field is worth, shown as its placeholder so a row left
|
||||
* blank does not read as a model with no capacity at all.
|
||||
*
|
||||
* The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s
|
||||
* `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person
|
||||
* would say them. They are a hint, not a mirror: this page counts `K` as 1000,
|
||||
* so typing `256K` stores 256000 while leaving the field blank keeps the
|
||||
* adapter's 262144. A deployment that overrides those defaults is not
|
||||
* reflected here — nothing on this page can read them.
|
||||
*/
|
||||
const CAPACITY_HINT: Readonly<Record<CapacityField, string>> = {
|
||||
contextWindow: '256K',
|
||||
maxTokens: '32K',
|
||||
}
|
||||
|
||||
/**
|
||||
* Spell a stored count for a field that may be unset. The spelling itself is
|
||||
* {@link formatCapacity}, shared with the DeepSeek catalog editor so both
|
||||
* surfaces read and write one K/M vocabulary.
|
||||
* @param value - stored capacity, or `undefined` for an unset field.
|
||||
* @returns the field text, empty when unset.
|
||||
*/
|
||||
function capacitySpelling(value: number | undefined): string {
|
||||
return value === undefined ? '' : formatCapacity(value)
|
||||
}
|
||||
|
||||
/** Adopt a candidate, keeping whatever capacities the provider disclosed. */
|
||||
function adopt(candidate: DiscoveredModelView): ModelDraft {
|
||||
return {
|
||||
id: candidate.id,
|
||||
...candidate.name === undefined ? {} : { name: candidate.name },
|
||||
...candidate.contextWindow === undefined ? {} : { contextWindow: candidate.contextWindow },
|
||||
...candidate.maxTokens === undefined ? {} : { maxTokens: candidate.maxTokens },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the model list with its fetch action.
|
||||
* @param props - the drafted rows, probe target, wire face, and copy.
|
||||
* @returns the model-list editor.
|
||||
*/
|
||||
export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
const { models, onChange, probe, api, t, disabled } = props
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
const [candidates, setCandidates] = useState<readonly DiscoveredModelView[] | undefined>(undefined)
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
|
||||
// Rows carry an id and a name; capacities are the exception, so they stay
|
||||
// folded until asked for rather than crowding every row with four inputs.
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set())
|
||||
// Capacities are edited as text, so a field's keystrokes are held here rather
|
||||
// than re-derived from the parsed count on every change — that would rewrite
|
||||
// `1000` to `1K` mid-word. Unreadable text is kept past blur so the refusal
|
||||
// names a row the user can still see, which is why this is one entry PER
|
||||
// FIELD: a single buffer would be displaced by editing any other field, and
|
||||
// the abandoned one would render its stored NaN as the literal `NaN`.
|
||||
const [editing, setEditing] = useState<ReadonlyMap<string, string>>(new Map())
|
||||
|
||||
/** Buffer key for one capacity field; the row half moves when rows do. */
|
||||
const bufferKey = (index: number, field: CapacityField): string => `${String(index)}:${field}`
|
||||
|
||||
const editCapacity = (index: number, field: CapacityField, text: string): void => {
|
||||
setEditing(current => new Map(current).set(bufferKey(index, field), text))
|
||||
patch(index, { [field]: parseCapacity(text) })
|
||||
}
|
||||
|
||||
/** What a capacity field shows: the buffer while typing, else the stored count. */
|
||||
const capacityText = (model: ModelDraft, index: number, field: CapacityField): string =>
|
||||
editing.get(bufferKey(index, field)) ?? capacitySpelling(numberOf(model, field))
|
||||
|
||||
/** Drop one row's entries and shift the rows after it down, in one pass. */
|
||||
const reindexOnRemove = (
|
||||
current: ReadonlyMap<string, string>,
|
||||
index: number,
|
||||
): Map<string, string> => {
|
||||
const next = new Map<string, string>()
|
||||
for (const [key, value] of current) {
|
||||
const at = Number(key.slice(0, key.indexOf(':')))
|
||||
if (at === index) continue
|
||||
// Only the row number moves; the field half of the key is untouched.
|
||||
next.set(at > index ? key.replace(/^\d+/, String(at - 1)) : key, value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const toggleExpanded = (index: number): void => {
|
||||
setExpanded((current) => {
|
||||
const next = new Set(current)
|
||||
if (!next.delete(index)) next.add(index)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const patch = (index: number, next: Record<string, string | number | undefined>): void => {
|
||||
onChange(models.map((model, at) => {
|
||||
if (at !== index) return model
|
||||
// Rebuilt rather than spread over: an emptied optional field has to leave
|
||||
// the profile, not be stored as a value its schema would reject.
|
||||
// Spread first so a field this card does not edit survives; an emptied
|
||||
// optional field is then dropped rather than stored as a value its
|
||||
// schema would reject.
|
||||
const cleared = new Set(
|
||||
Object.entries(next).filter(([, value]) => value === undefined || value === '').map(([key]) => key),
|
||||
)
|
||||
return Object.fromEntries(
|
||||
Object.entries({ ...model, ...next }).filter(([key]) => !cleared.has(key)),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
const fetchModels = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
try {
|
||||
const response = await api.llm.discoverModels({
|
||||
settingsNs: probe.settingsNs,
|
||||
...probe.provider === undefined ? {} : { provider: probe.provider },
|
||||
...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL },
|
||||
...probe.api === undefined ? {} : { api: probe.api },
|
||||
...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey },
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
}
|
||||
const found = response.result.value.models
|
||||
if (found.length === 0) {
|
||||
setFailure(t('fetchEmpty'))
|
||||
return
|
||||
}
|
||||
// Everything already configured starts unchecked, so adopting a
|
||||
// selection never silently rewrites a capacity the user corrected.
|
||||
const known = new Set(models.map(model => textOf(model, 'id')))
|
||||
setCandidates(found)
|
||||
setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; without this the button
|
||||
// would stay busy with nothing shown.
|
||||
setFailure(messageOf(error))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closePicker = (): void => {
|
||||
setCandidates(undefined)
|
||||
setPicked(new Set())
|
||||
}
|
||||
|
||||
const adoptPicked = (): void => {
|
||||
/* v8 ignore next -- the dialog only renders with candidates loaded */
|
||||
if (candidates === undefined) return
|
||||
const byId = new Map(models.map(model => [textOf(model, 'id'), model]))
|
||||
for (const candidate of candidates) {
|
||||
if (!picked.has(candidate.id)) continue
|
||||
// A row the user already tuned wins over the provider's own numbers.
|
||||
// Keyed by id, so a half-typed row whose id is still empty is not a
|
||||
// match and the candidate joins as its own row — correct, since a row
|
||||
// without an id is not yet a model and the create/apply gates refuse it.
|
||||
byId.set(candidate.id, byId.get(candidate.id) ?? adopt(candidate))
|
||||
}
|
||||
onChange([...byId.values()])
|
||||
closePicker()
|
||||
}
|
||||
|
||||
const toggle = (id: string): void => {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current)
|
||||
if (!next.delete(id)) next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// A route the adapter already describes answers without an endpoint; only a
|
||||
// draft with neither has nothing to ask about.
|
||||
const askable = probe.provider !== undefined || (probe.baseURL !== undefined && probe.baseURL.length > 0)
|
||||
return (
|
||||
<section className={styles['modelCatalog']} aria-label={t('models')}>
|
||||
<div className={styles['modelListHead']}>
|
||||
<div className={styles['modelCatalogHeading']}>
|
||||
<span className={styles['modelCatalogTitle']}>{t('models')}</span>
|
||||
{props.overridden === undefined
|
||||
? null
|
||||
: (
|
||||
<span className={styles['modelCatalogMeta']}>
|
||||
{props.overridden ? t('modelsCustomized') : t('modelsInherited')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{props.overridden === true && props.onReset !== undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['linkButton']}
|
||||
disabled={disabled}
|
||||
onClick={props.onReset}
|
||||
>
|
||||
{t('resetModels')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles['linkButton']}
|
||||
disabled={disabled || busy || !askable}
|
||||
title={askable ? undefined : t('fetchNeedsBaseUrl')}
|
||||
onClick={() => { void fetchModels() }}
|
||||
>
|
||||
{busy ? t('fetching') : t('fetchModels')}
|
||||
</button>
|
||||
</div>
|
||||
{models.length === 0 ? <p className={styles['modelEmpty']}>{t('modelsEmpty')}</p> : null}
|
||||
{models.map((model, index) => (
|
||||
<div key={index} className={styles['modelEntry']}>
|
||||
<div className={styles['modelRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={textOf(model, 'id')}
|
||||
placeholder={t('modelId')}
|
||||
aria-label={`${t('modelId')} ${index + 1}`}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { patch(index, { id: event.target.value }) }}
|
||||
/>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={textOf(model, 'name')}
|
||||
placeholder={t('modelName')}
|
||||
aria-label={`${t('modelName')} ${index + 1}`}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { patch(index, { name: event.target.value === '' ? undefined : event.target.value }) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['iconButton']}
|
||||
aria-label={`${t('modelAdvanced')} ${index + 1}`}
|
||||
aria-expanded={expanded.has(index)}
|
||||
title={t('modelAdvanced')}
|
||||
onClick={() => { toggleExpanded(index) }}
|
||||
>
|
||||
<IconChevron open={expanded.has(index)} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['iconButton']} ${styles['iconButtonDanger']}`}
|
||||
aria-label={`${t('removeModel')} ${index + 1}`}
|
||||
title={t('removeModel')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange(models.filter((_model, at) => at !== index))
|
||||
// Both stores are keyed by position, so every row after this
|
||||
// one shifts down and would otherwise inherit its neighbour's
|
||||
// state — a different row's capacities popping open, or its
|
||||
// half-typed text appearing in another row's field.
|
||||
setExpanded((current) => {
|
||||
const next = new Set<number>()
|
||||
for (const at of current) {
|
||||
if (at < index) next.add(at)
|
||||
else if (at > index) next.add(at - 1)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setEditing(current => reindexOnRemove(current, index))
|
||||
}}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
{expanded.has(index)
|
||||
? (
|
||||
<div className={styles['modelAdvanced']}>
|
||||
<label className={styles['modelField']}>
|
||||
<span className={styles['modelFieldLabel']}>{t('modelContextWindow')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={capacityText(model, index, 'contextWindow')}
|
||||
placeholder={CAPACITY_HINT.contextWindow}
|
||||
aria-label={`${t('modelContextWindow')} ${index + 1}`}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { editCapacity(index, 'contextWindow', event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles['modelField']}>
|
||||
<span className={styles['modelFieldLabel']}>{t('modelMaxTokens')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={capacityText(model, index, 'maxTokens')}
|
||||
placeholder={CAPACITY_HINT.maxTokens}
|
||||
aria-label={`${t('modelMaxTokens')} ${index + 1}`}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { editCapacity(index, 'maxTokens', event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addModelButton']}
|
||||
disabled={disabled}
|
||||
onClick={() => { onChange([...models, { id: '' }]) }}
|
||||
>
|
||||
{t('addModel')}
|
||||
</button>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
<Modal
|
||||
open={candidates !== undefined}
|
||||
onClose={closePicker}
|
||||
title={t('fetchTitle')}
|
||||
closeLabel={t('close')}
|
||||
description={t('fetchDescription')}
|
||||
className={styles['fetchDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={closePicker}>{t('cancel')}</Button>
|
||||
<Button variant="outline" onClick={adoptPicked}>{t('fetchAdopt')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ul className={styles['candidateList']}>
|
||||
{(candidates ?? []).map(candidate => (
|
||||
<li key={candidate.id} className={styles['candidate']}>
|
||||
<label className={styles['candidateLabel']}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.has(candidate.id)}
|
||||
onChange={() => { toggle(candidate.id) }}
|
||||
/>
|
||||
{/* The id alone: it is the string adoption writes, and the
|
||||
capacities the endpoint reported are adopted with it and
|
||||
editable in the row that appears. */}
|
||||
<span className={styles['candidateId']}>{candidate.id}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -295,11 +295,26 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The two ways to gain a provider, as equal siblings spanning the same width
|
||||
as the rows above. Wraps rather than shrinking below a legible label. */
|
||||
.addActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
/* Overrides the shared button base above: these two are not pills sitting in
|
||||
a footer but the last slot of the provider list, so they split the row
|
||||
evenly and repeat the row cards' corner. Dashed, like every other "nothing
|
||||
here yet" affordance on this page, to read as a place rather than a
|
||||
command. */
|
||||
flex: 1 1 0;
|
||||
min-width: 180px;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
height: 44px;
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.addCard,
|
||||
@@ -603,3 +618,44 @@ select.input {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fetchDialog {
|
||||
max-width: 520px;
|
||||
|
||||
/* The candidate list scrolls inside this dialog, an elevated surface, so the
|
||||
scrollbar indirection is rebound here rather than on the scrolling child:
|
||||
the elevation choice belongs with the surface and inherits down (see
|
||||
ui-theme styles/scrollbar.css for the contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.candidateList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.candidate {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.candidateLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidateId {
|
||||
flex: 1 1 auto;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
@@ -28,7 +29,7 @@ export interface ModelsSectionInjected {
|
||||
/** uSES subscription hook bound to the store. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Wire faces the editor writes through. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
@@ -151,10 +152,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
@@ -201,6 +204,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
||||
// Hand-declared routes live in the pi-ai namespace, which is also the only
|
||||
// one whose schema names the protocols one may speak; without it mounted
|
||||
// there is nothing to declare and the entry point stays disabled.
|
||||
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'))
|
||||
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
@@ -275,6 +282,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
setDeclaring(false)
|
||||
setAdding(false)
|
||||
setEditing(open ? undefined : target)
|
||||
}}
|
||||
@@ -354,25 +365,64 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={addable.length === 0 || !state.writable}
|
||||
onClick={() => {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the composer's attach button. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('add')}
|
||||
</button>
|
||||
)}
|
||||
: declaring
|
||||
? (
|
||||
<div className={styles['addCard']}>
|
||||
<CustomProviderCard
|
||||
taken={state.rows.map(row => row.entry.provider)}
|
||||
protocols={protocols}
|
||||
/* v8 ignore next -- the card only opens from a button disabled without this namespace */
|
||||
revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
// One row for the two ways to gain a provider: adopt one the
|
||||
// adapter already knows, or declare one it does not. Side by side
|
||||
// and equal-width so they read as siblings and line up with the
|
||||
// rows above, rather than two pills of different lengths.
|
||||
<div className={styles['addActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={addable.length === 0 || !state.writable}
|
||||
onClick={() => {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the composer's attach button. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import {
|
||||
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
|
||||
} from './DeepSeekModelsEditor.tsx'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
@@ -58,8 +60,8 @@ export interface ProviderEditorProps {
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
settingsPath: readonly string[]
|
||||
/** Wire faces for writes. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>
|
||||
/** Wire faces for writes and for interrogating a provider endpoint. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
@@ -172,6 +174,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
}
|
||||
|
||||
// The model list is validated by the same per-row checker for both families,
|
||||
// so a bad row is named by its position rather than by a blanket message.
|
||||
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
const probeBaseURL = stringAt(draft, 'baseURL') ?? stringAt(fallback, 'baseURL')
|
||||
const probe = {
|
||||
settingsNs: namespace.ns,
|
||||
// Naming the route lets an adapter that already describes it answer from
|
||||
// its own registry — better metadata, no network call, no endpoint needed.
|
||||
provider: props.provider,
|
||||
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
|
||||
...probeApi === undefined ? {} : { api: probeApi },
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
}
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
* path ops against the STORED section: the draft comes from the redacted
|
||||
@@ -188,10 +206,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
if (layout === 'deepseek') {
|
||||
const modelFailure = validateDeepSeekModels(getPath(next, ['models']))
|
||||
if (modelFailure !== undefined) {
|
||||
return `${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`
|
||||
{
|
||||
// The same checker gates the submit button, so a card cannot reach this
|
||||
// with a bad row; it stays because the schema check below would refuse
|
||||
// the write with a message naming a path instead of the row, and because
|
||||
// nothing but this function decides what is written.
|
||||
const failure = validateDeepSeekModels(getPath(next, ['models']))
|
||||
/* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */
|
||||
if (failure !== undefined) {
|
||||
return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}`
|
||||
}
|
||||
}
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
@@ -282,6 +305,17 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
: keyState?.configured === true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
overridden: modelsOverridden,
|
||||
t,
|
||||
disabled,
|
||||
onChange: (next: Record<string, unknown>[]) => {
|
||||
setDraft(current => setPath(current, ['models'], next))
|
||||
},
|
||||
onReset: () => { setDraft(current => deletePath(current, ['models'])) },
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles['field']}>
|
||||
@@ -333,22 +367,20 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
{family === 'deepseek'
|
||||
? (
|
||||
<DeepSeekModelsEditor
|
||||
models={models}
|
||||
overridden={modelsOverridden}
|
||||
{...catalogProps}
|
||||
defaultContextWindow={typeof defaultContextWindow === 'number'
|
||||
? defaultContextWindow
|
||||
: undefined}
|
||||
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
onChange={(next) => { setDraft(current => setPath(current, ['models'], next)) }}
|
||||
onReset={() => { setDraft(current => deletePath(current, ['models'])) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
@@ -371,24 +403,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
|
||||
: curatedFields(layout)}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
<div className={styles['editorActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
disabled={busy}
|
||||
onClick={() => { props.onClose(false) }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['primaryButton']}
|
||||
disabled={disabled || layout === 'unknown'}
|
||||
onClick={() => { void apply() }}
|
||||
>
|
||||
{busy ? t('applying') : t('apply')}
|
||||
</button>
|
||||
</div>
|
||||
{modelFailure === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['advancedHint']}>
|
||||
{`${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`}
|
||||
</p>
|
||||
)}
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
onSubmit={() => { void apply() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,29 @@ export const en = {
|
||||
modelContextInvalid: 'Context window must be a positive count, like 131072, 256K, or 1M.',
|
||||
modelMaxTokensInvalid: 'Max output tokens must be a positive count, like 8192, 64K, or 1M.',
|
||||
advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
|
||||
modelCapacityInvalid: 'A capacity must be a number, optionally suffixed K or M.',
|
||||
modelDuplicate: 'Each model ID may appear once.',
|
||||
modelContextWindow: 'Context window',
|
||||
modelMaxTokens: 'Max output tokens',
|
||||
fetchModels: 'Fetch available models',
|
||||
fetching: 'Asking the provider\u2026',
|
||||
fetchNeedsBaseUrl: 'Enter the base URL first, then fetch.',
|
||||
fetchEmpty: 'The provider listed no models. Add them by hand.',
|
||||
fetchTitle: 'Choose models to add',
|
||||
fetchDescription: 'These are the models this provider has available. Choose the ones to add.',
|
||||
fetchAdopt: 'Add selected',
|
||||
customAdd: 'Add a custom provider',
|
||||
customTitle: 'Custom provider',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
|
||||
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
customNeedsBaseUrl: 'A custom provider needs a base URL.',
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
creating: 'Creating\u2026',
|
||||
onboardingTitle: 'Add an API key to get started',
|
||||
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
|
||||
onboardingGoToSettings: 'Go to settings',
|
||||
@@ -127,6 +150,29 @@ export const zh: typeof en = {
|
||||
modelContextInvalid: '上下文窗口必须是正数,例如 131072、256K 或 1M。',
|
||||
modelMaxTokensInvalid: '最大输出 token 数必须是正数,例如 8192、64K 或 1M。',
|
||||
advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
|
||||
modelCapacityInvalid: '容量需为数字,可加 K 或 M 后缀。',
|
||||
modelDuplicate: '每个模型 ID 只能出现一次。',
|
||||
modelContextWindow: '上下文窗口',
|
||||
modelMaxTokens: '最大输出 token',
|
||||
fetchModels: '获取可用模型',
|
||||
fetching: '正在询问提供方\u2026',
|
||||
fetchNeedsBaseUrl: '请先填写 API 地址,再获取。',
|
||||
fetchEmpty: '该提供方没有列出任何模型,请手动添加。',
|
||||
fetchTitle: '选择要添加的模型',
|
||||
fetchDescription: '以下是模型提供方的可用模型,勾选要添加的模型。',
|
||||
fetchAdopt: '添加所选',
|
||||
customAdd: '添加自定义提供方',
|
||||
customTitle: '自定义提供方',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteInvalid: '只能使用小写字母、数字和短横线。',
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
creating: '创建中\u2026',
|
||||
onboardingTitle: '添加一个 API Key 开始使用',
|
||||
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
|
||||
onboardingGoToSettings: '前往配置',
|
||||
|
||||
@@ -11,7 +11,13 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form'
|
||||
|
||||
/**
|
||||
* Any route key walks a dict schema to the same profile node, so the lookup
|
||||
* names one that cannot collide with a configured route.
|
||||
*/
|
||||
const PROBE_ROUTE = '\u0000probe'
|
||||
|
||||
/** One provider row the page renders. */
|
||||
export interface ProviderRow {
|
||||
@@ -66,6 +72,22 @@ export function deriveKeyRef(provider: string): string {
|
||||
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire protocols a hand-declared route may name, read out of the owning
|
||||
* namespace's own schema. This stays a schema read rather than a wire field so
|
||||
* the choices the page offers cannot drift from the ones the adapter accepts:
|
||||
* both come from the same `Config`.
|
||||
* @param namespace - the namespace view whose schema declares the profile shape.
|
||||
* @returns the protocol identifiers, or an empty list when the schema has none.
|
||||
*/
|
||||
export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] {
|
||||
if (namespace === undefined) return []
|
||||
const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api'])
|
||||
const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined)
|
||||
if (list?.type !== 'union' || list.list === undefined) return []
|
||||
return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string')
|
||||
}
|
||||
|
||||
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
|
||||
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
|
||||
if (namespace === undefined) return undefined
|
||||
|
||||
Reference in New Issue
Block a user