feat(ui-models): schema-driven provider configuration page
The Models settings section joins llm.providers (the configurable directory with live state), settings.describe (schemas, layered redacted values, secret slots), and credentials.describe (value-free badges) into provider rows with one editor card at a time. The editor renders the provider's profile subtree through dsh-client-schema-form; the credential-ref role mounts a control that shows configured/source state and stores keys write-only through credentials.set. Apply without removals merges a minimal patch (stored secrets outside it survive); apply after a reset — and row deletion — replace the user section so removals land. The client runtime bridges the three new host frames to typed ctx events (settings/credentials/models changed), the page refetches on any of them once loaded, and ui-model's per-session picker directories reload on models/changed so a settings-born route appears in open pickers without a reopen.
This commit is contained in:
@@ -106,6 +106,28 @@ declare module 'cordis' {
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/changed'(): void
|
||||
/**
|
||||
* One settings namespace's resolved value changed on the host
|
||||
* (host/settings-changed passthrough). Subscribers refetch
|
||||
* `settings.describe`; the frame carries no values.
|
||||
* @mode emit
|
||||
* @param ns - the namespace whose resolved value changed.
|
||||
*/
|
||||
'settings/changed'(ns: string): void
|
||||
/**
|
||||
* One credential reference's state changed on the host
|
||||
* (host/credentials-changed passthrough). The ref is an
|
||||
* environment-variable NAME — never a value.
|
||||
* @mode emit
|
||||
* @param ref - the reference whose configured state changed.
|
||||
*/
|
||||
'credentials/changed'(ref: string): void
|
||||
/**
|
||||
* The host provider topology changed (host/models-changed passthrough).
|
||||
* Subscribers refetch `llm.providers`/`llm.models`/`session.models`.
|
||||
* @mode emit
|
||||
*/
|
||||
'models/changed'(): void
|
||||
/**
|
||||
* A connection generation was (re-)established. Wire-derived caches must
|
||||
* treat their state as stale and repull (commands directory; the queue
|
||||
@@ -144,8 +166,13 @@ export function apply(ctx: Context): void {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
// session routing); consumers (command directory caches, the settings
|
||||
// and model surfaces) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
|
||||
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
|
||||
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
|
||||
@@ -44,6 +44,22 @@ describe('wire event bridge', () => {
|
||||
expect(changed).toBe(1)
|
||||
})
|
||||
|
||||
it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => {
|
||||
const bench = await mount()
|
||||
const seen: unknown[][] = []
|
||||
bench.ctx.on('settings/changed', ns => seen.push(['settings', ns]))
|
||||
bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref]))
|
||||
bench.ctx.on('models/changed', () => seen.push(['models']))
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } })
|
||||
bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } })
|
||||
expect(seen).toEqual([
|
||||
['settings', 'llm-pi-ai'],
|
||||
['credentials', 'OPENAI_API_KEY'],
|
||||
['models'],
|
||||
])
|
||||
})
|
||||
|
||||
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
|
||||
const bench = await mount()
|
||||
let resets = 0
|
||||
|
||||
@@ -11,6 +11,6 @@ export type {
|
||||
SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
|
||||
} from './SchemaForm.tsx'
|
||||
export {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from './model.ts'
|
||||
export type { NodeKind, SchemaNode } from './model.ts'
|
||||
@@ -78,6 +78,26 @@ export function unionChoices(node: SchemaNode): unknown[] {
|
||||
return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the schema node at a settings path (the configurable-provider
|
||||
* directory's `settingsPath` vocabulary): object properties by name, dict
|
||||
* entries through `inner`. An unresolvable segment returns `undefined` so
|
||||
* the caller falls back instead of rendering a wrong subtree.
|
||||
* @param root - rehydrated section root node.
|
||||
* @param path - key path from the section root.
|
||||
* @returns the node describing that position, or `undefined`.
|
||||
*/
|
||||
export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
|
||||
let node: SchemaNode | undefined = root
|
||||
for (const key of path) {
|
||||
if (node === undefined) return undefined
|
||||
if (node.type === 'object') node = (node.dict as Record<string, SchemaNode> | undefined)?.[key]
|
||||
else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined
|
||||
else return undefined
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value by path.
|
||||
* @param value - root value (draft or fallback layer).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
@@ -101,3 +101,27 @@ describe('path helpers', () => {
|
||||
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeAtPath', () => {
|
||||
const Root = Schema.object({
|
||||
providers: Schema.dict(Schema.object({ baseURL: Schema.string() })),
|
||||
models: Schema.array(Schema.object({ id: Schema.string() })),
|
||||
leaf: Schema.string(),
|
||||
})
|
||||
|
||||
it('resolves object, dict, and array positions', () => {
|
||||
const root = rehydrateSchema(Wire(Root))
|
||||
expect(nodeAtPath(root, [])).toBe(root)
|
||||
expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object')
|
||||
expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string')
|
||||
expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string')
|
||||
expect(nodeAtPath(root, ['missing'])).toBeUndefined()
|
||||
expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined()
|
||||
expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates structural nodes missing their relation maps', () => {
|
||||
expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined()
|
||||
expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,14 @@ export class ModelService extends Service {
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const directory of this.live.directories.values()) directory.resetConnected()
|
||||
})
|
||||
// Provider topology changed on the host (a settings-born route appeared
|
||||
// or dropped): refresh every open directory in the background so pickers
|
||||
// show the new catalog without a reopen. Failures stay on each store.
|
||||
ctx.on('models/changed', () => {
|
||||
for (const directory of this.live.directories.values()) {
|
||||
directory.load().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,17 +36,23 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Credential-reference control: renders the reference NAME as the editable
|
||||
* settings field, its configured state as a badge, and an inline write-only
|
||||
* key input that stores the value through `credentials.set`. The value never
|
||||
* renders back — the wire has no read path for it.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CredentialView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SchemaFieldContext } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props of {@link CredentialControl}. */
|
||||
export interface CredentialControlProps {
|
||||
/** The `apiKeyEnv` leaf position inside the provider editor's form. */
|
||||
context: SchemaFieldContext
|
||||
/** Credentials wire face. */
|
||||
credentials: IApiClient['credentials']
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** The effective reference name this control addresses. */
|
||||
function refOf(context: SchemaFieldContext): string | undefined {
|
||||
const value = context.draftValue ?? context.fallbackValue
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the credential-reference field with its live state and key input.
|
||||
* @param props - field context, wire face, and copy.
|
||||
* @returns the control column.
|
||||
*/
|
||||
export function CredentialControl(props: CredentialControlProps): ReactNode {
|
||||
const { context, credentials, t } = props
|
||||
const ref = refOf(context)
|
||||
const [state, setState] = useState<CredentialView | undefined>(undefined)
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
setState(undefined)
|
||||
if (ref === undefined) return undefined
|
||||
void credentials.describe({ refs: [ref] }).then((response) => {
|
||||
if (stale || !response.result.ok) return
|
||||
setState(response.result.value.credentials[ref])
|
||||
})
|
||||
return () => { stale = true }
|
||||
}, [credentials, ref])
|
||||
|
||||
const badge = state === undefined
|
||||
? null
|
||||
: state.configured
|
||||
? (
|
||||
<span className={styles['badgeOk']}>
|
||||
{t('credentialConfigured')}
|
||||
{state.source === 'env' ? ` · ${t('credentialFromEnv')}` : ''}
|
||||
</span>
|
||||
)
|
||||
: <span className={styles['badgeWarn']}>{t('credentialMissing')}</span>
|
||||
|
||||
const storeKey = async (): Promise<void> => {
|
||||
/* v8 ignore next -- the save button is disabled while no reference or draft exists */
|
||||
if (ref === undefined || keyDraft.length === 0) return
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
const response = await credentials.set({ ref, value: keyDraft })
|
||||
setBusy(false)
|
||||
if (!response.result.ok) {
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
}
|
||||
setKeyDraft('')
|
||||
const described = await credentials.describe({ refs: [ref] })
|
||||
if (described.result.ok) setState(described.result.value.credentials[ref])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['credential']}>
|
||||
<div className={styles['credentialRefRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={typeof context.draftValue === 'string' ? context.draftValue : ''}
|
||||
placeholder={typeof context.fallbackValue === 'string' ? context.fallbackValue : undefined}
|
||||
aria-label={t('credentialRef')}
|
||||
disabled={context.disabled}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
if (next === '') context.clearValue()
|
||||
else context.setValue(next)
|
||||
}}
|
||||
/>
|
||||
{badge}
|
||||
</div>
|
||||
{ref !== undefined && state?.writable !== false
|
||||
? (
|
||||
<div className={styles['credentialKeyRow']}>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
disabled={context.disabled || busy}
|
||||
aria-label={t('keyInput')}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
disabled={context.disabled || busy || keyDraft.length === 0}
|
||||
onClick={() => { void storeKey() }}
|
||||
>
|
||||
{t('keySave')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-warning, #a15c00);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowCard {
|
||||
border: 1px solid var(--border, #e2e2e2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badgeOk {
|
||||
color: var(--text-success, #0a7d33);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badgeMuted {
|
||||
color: var(--text-tertiary, #999);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
color: var(--text-warning, #a15c00);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 8px 18px;
|
||||
background: var(--accent-strong, #111);
|
||||
color: var(--text-inverse, #fff);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-danger, #c0392b);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryButton:disabled,
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor {
|
||||
border-top: 1px solid var(--border, #eee);
|
||||
padding-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editorHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editorTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addSelect {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
background: var(--surface, #fff);
|
||||
}
|
||||
|
||||
.credential {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.credentialRefRow,
|
||||
.credentialKeyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credentialRefRow > input,
|
||||
.credentialKeyRow > input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-danger, #c0392b);
|
||||
}
|
||||
@@ -1,13 +1,218 @@
|
||||
/**
|
||||
* Models settings section: an intentionally empty content column — the nav
|
||||
* entry exists so the section slot composition is visible; model management
|
||||
* lands in a later phase.
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time (edit an existing provider or add a dormant one). Every
|
||||
* mutation writes through the wire; the page re-renders from the pushed
|
||||
* invalidations or the post-apply reload.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render the (empty) Models section content column.
|
||||
* @returns null — no content this phase.
|
||||
*/
|
||||
export function ModelsSection() {
|
||||
return null
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { deletePath } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Injected dependencies of {@link ModelsSection} (slot `inject`). */
|
||||
export interface ModelsSectionInjected {
|
||||
/** The page store (loaded on mount, refreshed on pushed invalidations). */
|
||||
controller: ModelsSettingsStore
|
||||
/** uSES subscription hook bound to the store. */
|
||||
useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
|
||||
/** Wire faces the editor and credential control write through. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
}
|
||||
|
||||
/** Props delivered by the slot outlet. */
|
||||
export interface ModelsSectionProps {
|
||||
injected?: ModelsSectionInjected
|
||||
}
|
||||
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
provider: string
|
||||
settingsNs: string
|
||||
settingsPath: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile from its namespace's user section
|
||||
* (wholesale replace — merge cannot express a removal) and reload on success.
|
||||
* @param api - settings wire face.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param namespace - the owning namespace view.
|
||||
* @returns settles when the write and any reload finished.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
namespace: SettingsNamespaceView,
|
||||
): Promise<void> {
|
||||
const user = structuredClone((namespace.user ?? {}) as Record<string, unknown>)
|
||||
const next = deletePath(user, [...target.settingsPath])
|
||||
const response = await api.settings.replace({ ns: target.settingsNs, section: next })
|
||||
if (response.result.ok) await controller.load()
|
||||
}
|
||||
|
||||
function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['t'] }): ReactNode {
|
||||
return (
|
||||
<span className={styles['badges']}>
|
||||
{row.entry.active
|
||||
? <span className={styles['badgeOk']}>{t('active')}</span>
|
||||
: <span className={styles['badgeMuted']}>{t('dormant')}</span>}
|
||||
{row.credential !== undefined && !row.credential.configured
|
||||
? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
|
||||
: null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Models section content column.
|
||||
* @param props - slot-delivered injected dependencies.
|
||||
* @returns the section, or null while the shell has not injected yet.
|
||||
*/
|
||||
export function ModelsSection(props: ModelsSectionProps): ReactNode {
|
||||
const injected = props.injected
|
||||
if (injected === undefined) return null
|
||||
return <Loaded injected={injected} />
|
||||
}
|
||||
|
||||
function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const { controller, api, t } = injected
|
||||
const state = injected.useSnapshot(snapshot => snapshot)
|
||||
const [editing, setEditing] = useState<EditorTarget | undefined>(undefined)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
if (changed) void controller.load()
|
||||
}
|
||||
|
||||
if (state.status === 'idle') void controller.load()
|
||||
if (state.status === 'error') {
|
||||
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
||||
const errorText = state.error ?? ''
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
<p className={styles['error']}>{`${t('loadFailed')}: ${errorText}`}</p>
|
||||
<button type="button" className={styles['secondaryButton']} onClick={() => { void controller.load() }}>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
|
||||
|
||||
return (
|
||||
<div className={styles['section']}>
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
<ul className={styles['rows']}>
|
||||
{configured.map((row) => {
|
||||
const target: EditorTarget = {
|
||||
provider: row.entry.provider,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const namespace = state.namespaces.get(target.settingsNs)
|
||||
/* v8 ignore next -- the join marks a row configured only when its namespace resolved */
|
||||
if (namespace === undefined) return null
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['rowCard']}>
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
<StatusBadges row={row} t={t} />
|
||||
<span className={styles['rowActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
onClick={() => { setAdding(false); setEditing(open ? undefined : target) }}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
{row.removable
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { void removeProviderProfile(api, controller, target, namespace) }}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
{open
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className={styles['addBlock']}>
|
||||
{addTarget !== undefined && addNamespace !== undefined
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={addTarget.provider}
|
||||
namespace={addNamespace}
|
||||
settingsPath={addTarget.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<select
|
||||
className={styles['addSelect']}
|
||||
value=""
|
||||
disabled={addable.length === 0 || !state.writable}
|
||||
aria-label={t('add')}
|
||||
onChange={(event) => {
|
||||
const row = addable.find(candidate => candidate.entry.provider === event.target.value)
|
||||
if (row === undefined) return
|
||||
setAdding(true)
|
||||
setEditing({
|
||||
provider: row.entry.provider,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">{`+ ${t('add')}`}</option>
|
||||
{addable.map(row => (
|
||||
<option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* One provider's editor card: the schema-driven form over its profile
|
||||
* subtree, the credential-reference control, and the Apply/Cancel pair.
|
||||
* Apply without removals merges (`settings.update`, preserving stored keys
|
||||
* outside the patch); apply after a field reset replaces the user section so
|
||||
* the reset actually lands.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
getPath, nodeAtPath, rehydrateSchema, SchemaForm, setPath, validateDraft,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import type { SchemaFormSecret } from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { CredentialControl } from './CredentialControl.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
/** Props of {@link ProviderEditor}. */
|
||||
export interface ProviderEditorProps {
|
||||
/** Provider route id (card title). */
|
||||
provider: string
|
||||
/** The owning namespace view (schema, layers, secrets). */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
settingsPath: readonly string[]
|
||||
/** Wire faces for writes. */
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
onClose: (changed: boolean) => void
|
||||
}
|
||||
|
||||
/** Secrets re-rooted at the profile subtree (paths relative to the editor's form). */
|
||||
function secretsUnder(namespace: SettingsNamespaceView, path: readonly string[]): SchemaFormSecret[] {
|
||||
return namespace.secrets.flatMap((secret) => {
|
||||
if (secret.path.length < path.length) return []
|
||||
if (!path.every((key, index) => secret.path[index] === key)) return []
|
||||
return [{ path: secret.path.slice(path.length), set: secret.set }]
|
||||
})
|
||||
}
|
||||
|
||||
/** A user-section subtree as a plain draft object (absent → empty). */
|
||||
function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
|
||||
const subtree = getPath(namespace.user, path)
|
||||
if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {}
|
||||
return structuredClone(subtree) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Whether any key present in `before` is absent from `after` (a reset happened). */
|
||||
function removedAny(before: unknown, after: unknown): boolean {
|
||||
if (typeof before !== 'object' || before === null) return false
|
||||
/* v8 ignore next -- the form edits containers in place; a container cannot become a primitive */
|
||||
if (typeof after !== 'object' || after === null) return true
|
||||
for (const [key, value] of Object.entries(before)) {
|
||||
if (!(key in (after as Record<string, unknown>))) return true
|
||||
if (removedAny(value, (after as Record<string, unknown>)[key])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one provider's editing card.
|
||||
* @param props - the addressed profile plus wire faces and copy.
|
||||
* @returns the editor card.
|
||||
*/
|
||||
export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const { namespace, settingsPath, api, t } = props
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const subtreeSchema = useMemo(() => node?.toJSON(), [node])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
const secrets = useMemo(() => secretsUnder(namespace, settingsPath), [namespace, settingsPath])
|
||||
|
||||
const apply = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setFailure(undefined)
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
const needsReplace = removedAny(original, draft)
|
||||
// Merge patches stay minimal (just this profile); a replace must carry
|
||||
// the complete next user section because it lands wholesale.
|
||||
const patch = settingsPath.length === 0 ? draft : setPath({}, [...settingsPath], draft)
|
||||
/* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
|
||||
const nextSection = settingsPath.length === 0
|
||||
? draft
|
||||
: setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], draft)
|
||||
/* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
|
||||
if (node !== undefined) {
|
||||
const sectionError = settingsPath.length === 0 ? validateDraft(node, draft) : undefined
|
||||
if (sectionError !== undefined) {
|
||||
setBusy(false)
|
||||
setFailure(sectionError)
|
||||
return
|
||||
}
|
||||
}
|
||||
const response = needsReplace
|
||||
? await api.settings.replace({ ns, section: nextSection })
|
||||
: await api.settings.update({ ns, patch })
|
||||
setBusy(false)
|
||||
if (!response.result.ok) {
|
||||
setFailure(response.result.error.message)
|
||||
return
|
||||
}
|
||||
props.onClose(true)
|
||||
}
|
||||
|
||||
if (node === undefined || subtreeSchema === undefined) {
|
||||
// A directory entry addressing a position its schema cannot resolve is a
|
||||
// host-side inconsistency; showing it beats a blank card.
|
||||
return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['editor']}>
|
||||
<div className={styles['editorHeader']}>
|
||||
<span className={styles['editorTitle']}>{props.provider}</span>
|
||||
</div>
|
||||
<SchemaForm
|
||||
schema={subtreeSchema}
|
||||
draft={draft}
|
||||
fallback={fallback}
|
||||
secrets={secrets}
|
||||
disabled={props.readOnly || busy}
|
||||
onChange={setDraft}
|
||||
labels={{
|
||||
reset: t('reset'),
|
||||
add: t('addLabel'),
|
||||
remove: t('removeLabel'),
|
||||
secretSet: t('secretSet'),
|
||||
secretUnset: t('secretUnset'),
|
||||
inherited: t('inherited'),
|
||||
unsupported: t('unsupported'),
|
||||
}}
|
||||
renderField={(context) => {
|
||||
if (context.role !== 'credential-ref') return undefined
|
||||
return <CredentialControl context={context} credentials={api.credentials} t={t} />
|
||||
}}
|
||||
/>
|
||||
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
|
||||
<div className={styles['editorActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
disabled={busy}
|
||||
onClick={() => { props.onClose(false) }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['primaryButton']}
|
||||
disabled={props.readOnly || busy}
|
||||
onClick={() => { void apply() }}
|
||||
>
|
||||
{busy ? t('applying') : t('apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,90 @@
|
||||
/**
|
||||
* Models settings section plugin, browser half. Registers the `models` nav
|
||||
* entry into the shell-declared `settings.section` list slot; the content
|
||||
* column is intentionally empty until model management lands. Export
|
||||
* discipline: packages/client/AGENTS.md.
|
||||
* entry into the shell-declared `settings.section` list slot and mounts the
|
||||
* provider configuration page: the configurable-provider directory joined
|
||||
* with settings namespaces and credential states, edited through the
|
||||
* schema-driven form. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ModelsSection } from './ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from './ModelsSection.tsx'
|
||||
import { ModelsSettingsStore } from './store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type { ModelsSectionInjected, ModelsSectionProps } from './ModelsSection.tsx'
|
||||
export type { ModelsSettingsState, ProviderRow } from './store.ts'
|
||||
|
||||
/**
|
||||
* Refetch the page snapshot only after its first load: an unopened Models
|
||||
* page must not fetch on background invalidations.
|
||||
* @param controller - the page store.
|
||||
*/
|
||||
export function refreshIfLoaded(controller: ModelsSettingsStore): void {
|
||||
if (controller.store.getSnapshot().status === 'idle') return
|
||||
void controller.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slot is declared by
|
||||
* ui-settings' apply, whose activation order relative to this one is NOT
|
||||
* constrained; registration goes through declaration-aware deferral.
|
||||
*/
|
||||
export const inject = ['slots', 'locale']
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
|
||||
/**
|
||||
* Register the Models section once the `settings.section` declaration is on
|
||||
* the ledger.
|
||||
* the ledger, wire its store to the connection, and keep it fresh on every
|
||||
* pushed invalidation (settings, credentials, or provider topology).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register('settings.models', 'zh', { nav: '模型' }),
|
||||
ctx.locale.register('settings.models', 'en', { nav: 'Models' }),
|
||||
ctx.locale.register('settings.models', 'zh', zh),
|
||||
ctx.locale.register('settings.models', 'en', en),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-models: nav copy dictionaries')
|
||||
}, 'ui-models: copy dictionaries')
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new ModelsSettingsStore(connection.api)
|
||||
const useSnapshot = bindSnapshotSelector(controller.store)
|
||||
const t = ctx.locale.bind('settings.models') as ModelsSectionInjected['t']
|
||||
const injected = (): ModelsSectionInjected => ({
|
||||
controller,
|
||||
useSnapshot,
|
||||
api: connection.api,
|
||||
t,
|
||||
})
|
||||
|
||||
// Pushed invalidations converge every open surface without polling: any
|
||||
// settings/credentials/topology change refetches once the page loaded.
|
||||
ctx.effect(() => {
|
||||
const refresh = (): void => { refreshIfLoaded(controller) }
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
ctx.on('credentials/changed', refresh),
|
||||
ctx.on('models/changed', refresh),
|
||||
ctx.on('connection/reset', refresh),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-models: pushed invalidations')
|
||||
|
||||
ctx.effect(() => {
|
||||
const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () =>
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'models',
|
||||
order: 10,
|
||||
label: ctx.locale.bind('settings.models')('nav'),
|
||||
label: t('nav'),
|
||||
inject: injected,
|
||||
}, ModelsSection))
|
||||
// Nav labels are registrant-localized: refresh on locale change so the
|
||||
// ledger carries fresh text (the version bump re-renders the shell).
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Copy dictionaries for the Models settings section. */
|
||||
|
||||
/** English strings. */
|
||||
export const en = {
|
||||
nav: 'Models',
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
active: 'Active',
|
||||
dormant: 'Inactive',
|
||||
keyMissing: 'No API key',
|
||||
edit: 'Edit',
|
||||
remove: 'Delete',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
retry: 'Retry',
|
||||
credentialRef: 'API key environment variable',
|
||||
credentialConfigured: 'Configured',
|
||||
credentialFromEnv: 'from the launch environment (read-only)',
|
||||
credentialMissing: 'Not configured',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter a key to store it',
|
||||
keySave: 'Save key',
|
||||
keyClear: 'Clear key',
|
||||
reset: 'Reset',
|
||||
addLabel: 'Add',
|
||||
removeLabel: 'Remove',
|
||||
secretSet: 'Configured — enter a new value to replace',
|
||||
secretUnset: 'Not configured',
|
||||
inherited: 'Default',
|
||||
unsupported: 'This field has no form control; edit the settings document directly.',
|
||||
}
|
||||
|
||||
/** Chinese strings (same keys as {@link en}). */
|
||||
export const zh: typeof en = {
|
||||
nav: '模型',
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
active: '已启用',
|
||||
dormant: '未启用',
|
||||
keyMissing: '缺少密钥',
|
||||
edit: '编辑',
|
||||
remove: '删除',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
retry: '重试',
|
||||
credentialRef: 'API 密钥环境变量',
|
||||
credentialConfigured: '已配置',
|
||||
credentialFromEnv: '来自启动环境(只读)',
|
||||
credentialMissing: '未配置',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入密钥以保存',
|
||||
keySave: '保存密钥',
|
||||
keyClear: '清除密钥',
|
||||
reset: '重置',
|
||||
addLabel: '添加',
|
||||
removeLabel: '移除',
|
||||
secretSet: '已设置——输入新值可替换',
|
||||
secretUnset: '未设置',
|
||||
inherited: '默认',
|
||||
unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Models settings page store: one snapshot joining the configurable-provider
|
||||
* directory (`llm.providers`), the settings namespaces (`settings.describe`),
|
||||
* and the referenced credentials (`credentials.describe`). The host stays the
|
||||
* single fact source — every mutation writes through the wire and the page
|
||||
* re-renders from the next describe, pushed or refetched.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConfigurableProviderView, CredentialView, IApiClient, SettingsNamespaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { getPath, hasPath } from '@deepseek-ai/dsh-client-schema-form'
|
||||
|
||||
/** One provider row the page renders. */
|
||||
export interface ProviderRow {
|
||||
/** The directory entry (route id, display name, settings address, live state). */
|
||||
entry: ConfigurableProviderView
|
||||
/** Whether any layer configures this provider (its profile resolves). */
|
||||
configured: boolean
|
||||
/** Whether the user layer alone carries the profile (removal restores the base). */
|
||||
removable: boolean
|
||||
/** The credential reference the resolved profile names, when one does. */
|
||||
apiKeyEnv: string | undefined
|
||||
/** Credential state for {@link apiKeyEnv}, once described. */
|
||||
credential: CredentialView | undefined
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
export interface ModelsSettingsState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||||
/** Whole-load failure text; row-level write failures stay in the editor. */
|
||||
error: string | null
|
||||
/** Whether the settings provider accepts writes. */
|
||||
writable: boolean
|
||||
/** Every configurable provider joined with its configured/credential state. */
|
||||
rows: readonly ProviderRow[]
|
||||
/** Namespace views by ns, for the editor's schema/layers/secrets. */
|
||||
namespaces: ReadonlyMap<string, SettingsNamespaceView>
|
||||
}
|
||||
|
||||
/** The credential reference a resolved profile names (its `apiKeyEnv` field). */
|
||||
function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
|
||||
if (namespace === undefined) return undefined
|
||||
const profile = getPath(namespace.value, path)
|
||||
if (typeof profile !== 'object' || profile === null) return undefined
|
||||
const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv
|
||||
return typeof ref === 'string' && ref.length > 0 ? ref : undefined
|
||||
}
|
||||
|
||||
/** The models settings page controller (one per settings surface). */
|
||||
export class ModelsSettingsStore {
|
||||
/** The snapshot the section renders from (uSES-safe store). */
|
||||
readonly store: SnapshotStore<ModelsSettingsState> = createSnapshotStore<ModelsSettingsState>({
|
||||
status: 'idle', error: null, writable: false, rows: [], namespaces: new Map(),
|
||||
})
|
||||
|
||||
/** Latest load wins; an older response never overwrites a newer one. */
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* @param api - the wire face (settings/credentials/llm domains).
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
* failure keeps the last good rows and surfaces the error.
|
||||
* @returns nothing; the snapshot carries the outcome.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'loading'; s.error = null })
|
||||
let providers: ConfigurableProviderView[]
|
||||
let writable: boolean
|
||||
let views: SettingsNamespaceView[]
|
||||
try {
|
||||
const [providersResponse, settingsResponse] = await Promise.all([
|
||||
this.api.llm.providers({}),
|
||||
this.api.settings.describe({}),
|
||||
])
|
||||
if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message)
|
||||
if (!settingsResponse.result.ok) throw new Error(settingsResponse.result.error.message)
|
||||
providers = providersResponse.result.value.providers
|
||||
writable = settingsResponse.result.value.writable
|
||||
views = settingsResponse.result.value.namespaces
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
const namespaces = new Map(views.map(view => [view.ns, view]))
|
||||
const rows: ProviderRow[] = providers.map((entry) => {
|
||||
const namespace = namespaces.get(entry.settingsNs)
|
||||
const configured = namespace !== undefined
|
||||
&& (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined)
|
||||
const removable = namespace !== undefined
|
||||
&& entry.settingsPath.length > 0
|
||||
&& hasPath(namespace.user, entry.settingsPath)
|
||||
&& !hasPath(namespace.base, entry.settingsPath)
|
||||
return {
|
||||
entry,
|
||||
configured,
|
||||
removable,
|
||||
apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath),
|
||||
credential: undefined,
|
||||
}
|
||||
})
|
||||
const refs = [...new Set(rows.flatMap(row => row.apiKeyEnv === undefined ? [] : [row.apiKeyEnv]))]
|
||||
let credentials: Record<string, CredentialView> = {}
|
||||
if (refs.length > 0) {
|
||||
const response = await this.api.credentials.describe({ refs })
|
||||
// Credential state is an enrichment: rows render without it, so a
|
||||
// missing credential provider degrades the badge, not the page.
|
||||
if (response.result.ok) credentials = response.result.value.credentials
|
||||
}
|
||||
if (generation !== this.generation) return
|
||||
this.store.update((s) => {
|
||||
s.status = 'ready'
|
||||
s.error = null
|
||||
s.writable = writable
|
||||
s.rows = rows.map(row => ({
|
||||
...row,
|
||||
...row.apiKeyEnv !== undefined && credentials[row.apiKeyEnv] !== undefined
|
||||
? { credential: credentials[row.apiKeyEnv] }
|
||||
: {},
|
||||
}))
|
||||
s.namespaces = namespaces
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
|
||||
async function bench() {
|
||||
@@ -11,6 +11,9 @@ async function bench() {
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
// The apply path only captures the wire face; no call leaves this fake
|
||||
// until a section actually loads.
|
||||
ctx.provide('connection', { api: {} } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale }
|
||||
}
|
||||
|
||||
@@ -23,7 +26,7 @@ function declare(slots: SlotsService): () => void {
|
||||
|
||||
describe('ui-models apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('registers the models nav entry for declarations before or after apply', async () => {
|
||||
@@ -32,7 +35,12 @@ describe('ui-models apply', () => {
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = before.slots.entries('settings.section')[0]!
|
||||
expect(entry.component).toBe(ModelsSection)
|
||||
expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' })
|
||||
expect(entry.options).toMatchObject({ id: 'models', order: 10, label: '模型' })
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -93,3 +101,32 @@ describe('ui-models apply', () => {
|
||||
expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pushed invalidations', () => {
|
||||
it('ignores invalidations before the page ever loaded', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// The fake wire face has no methods: a fetch attempt would throw.
|
||||
b.ctx.emit('settings/changed', 'llm-pi-ai')
|
||||
b.ctx.emit('credentials/changed', 'OPENAI_API_KEY')
|
||||
b.ctx.emit('models/changed')
|
||||
b.ctx.emit('connection/reset')
|
||||
})
|
||||
|
||||
it('refreshes a loaded page and skips an idle one', () => {
|
||||
const loads: number[] = []
|
||||
const controller = {
|
||||
store: { getSnapshot: () => ({ status: 'ready' }) },
|
||||
load: () => { loads.push(1); return Promise.resolve() },
|
||||
}
|
||||
refreshIfLoaded(controller as unknown as import('../src/client/store.ts').ModelsSettingsStore)
|
||||
expect(loads).toHaveLength(1)
|
||||
const idle = {
|
||||
store: { getSnapshot: () => ({ status: 'idle' }) },
|
||||
load: () => { loads.push(2); return Promise.resolve() },
|
||||
}
|
||||
refreshIfLoaded(idle as unknown as import('../src/client/store.ts').ModelsSettingsStore)
|
||||
expect(loads).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Section, editor, and credential-control behavior over a scripted wire face. */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
|
||||
const PiAiConfig = Schema.object({
|
||||
token: Schema.string().role('secret'),
|
||||
providers: Schema.dict(Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
headers: Schema.dict(Schema.string()),
|
||||
})),
|
||||
})
|
||||
|
||||
const DeepSeekConfig = Schema.object({
|
||||
apiKey: Schema.string().role('secret'),
|
||||
apiKeyEnv: Schema.string().role('credential-ref'),
|
||||
baseURL: Schema.string(),
|
||||
label: Schema.string().required(),
|
||||
})
|
||||
|
||||
function wireNamespaces(): SettingsNamespaceView[] {
|
||||
return [
|
||||
{
|
||||
ns: 'llm-deepseek',
|
||||
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||||
base: { baseURL: 'https://base' },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
},
|
||||
{
|
||||
ns: 'llm-pi-ai',
|
||||
schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
|
||||
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
applies: 'live',
|
||||
secrets: [{ path: ['token'], set: false }, { path: ['providers', 'openai', 'apiKey'], set: false }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
function fail<T>(message: string, code = 'settings-rejected'): RpcResponse<T> {
|
||||
return {
|
||||
rpcId: `r-${nextRpc++}` as never,
|
||||
result: { ok: false, error: { code, message, details: { ns: 'x' } } as never },
|
||||
}
|
||||
}
|
||||
|
||||
function scriptedFace(overrides: {
|
||||
update?: ReturnType<typeof vi.fn>
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: vi.fn(() => Promise.resolve(ok({
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
|
||||
{ provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
|
||||
],
|
||||
}))),
|
||||
models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
|
||||
},
|
||||
settings: {
|
||||
describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: wireNamespaces() }))),
|
||||
update,
|
||||
replace,
|
||||
},
|
||||
credentials: {
|
||||
describe: vi.fn((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
|
||||
configured: ref === 'OPENAI_API_KEY',
|
||||
...ref === 'OPENAI_API_KEY' ? { source: 'file' } : {},
|
||||
writable: true,
|
||||
}])),
|
||||
}))),
|
||||
set,
|
||||
unset: vi.fn(() => Promise.resolve(ok({}))),
|
||||
},
|
||||
}
|
||||
return { face, update, replace, set }
|
||||
}
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, set } = scriptedFace(overrides)
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection injected={injected} />)
|
||||
return { view, face, update, replace, set, controller }
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
it('renders configured rows with status badges and the add vocabulary', async () => {
|
||||
await mountSection()
|
||||
expect(screen.getByText('DeepSeek')).toBeTruthy()
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('anthropic', { selector: 'span' })).toBeNull()
|
||||
expect(screen.getAllByText(en.active)).toHaveLength(2)
|
||||
// A configured profile whose route did not register renders dormant.
|
||||
expect(screen.getByText(en.dormant)).toBeTruthy()
|
||||
expect(screen.getByText(en.keyMissing)).toBeTruthy()
|
||||
const add = screen.getByLabelText<HTMLSelectElement>(en.add)
|
||||
expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic', 'broken'])
|
||||
expect(screen.getAllByText(en.remove)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
|
||||
const { update, face } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://next', headers: { 'X-Team': 'a' } } } },
|
||||
})
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
})
|
||||
|
||||
it('applies a field reset through replace so the removal lands', async () => {
|
||||
const { replace, update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
section: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', headers: { 'X-Team': 'a' } }, zombie: {} } },
|
||||
})
|
||||
})
|
||||
|
||||
it('lands a nested removal (dict entry) through replace', async () => {
|
||||
const { replace } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
// Row deletion says "Delete"; the only "Remove" inside the open editor
|
||||
// is schema-form's headers-dict row control.
|
||||
fireEvent.click(screen.getAllByText(en.removeLabel)[0] as HTMLElement)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
const section = (replace.mock.calls[0]?.[0] as { section: { providers: { openai: { headers?: unknown } } } }).section
|
||||
expect(section.providers.openai.headers).toEqual({})
|
||||
})
|
||||
|
||||
it('surfaces a rejected apply inside the editor', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const baseURL = await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.change(baseURL, { target: { value: 'https://next' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText('llm-pi-ai: unknown pi-ai provider "bogus"')
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('adds a dormant provider through the add select and merges its profile in', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'anthropic' } })
|
||||
const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
|
||||
// No reference yet, so the write-only key input stays hidden until one exists.
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
fireEvent.change(ref, { target: { value: 'ANTHROPIC_API_KEY' } })
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(key.placeholder).toBe(en.keyPlaceholder)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
expect(update.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a user-added provider through replace', async () => {
|
||||
const { replace } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
|
||||
})
|
||||
|
||||
it('reports an unresolvable settings path instead of a blank editor', async () => {
|
||||
await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'broken' } })
|
||||
await screen.findByText(/unresolvable settings path/)
|
||||
})
|
||||
|
||||
it('clears the credential reference back to inherited from the control', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
|
||||
expect(ref.value).toBe('OPENAI_API_KEY')
|
||||
fireEvent.change(ref, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(0) })
|
||||
// Dropping the reference is a removal, so it lands through replace.
|
||||
})
|
||||
|
||||
it('shows the env-shadowed credential badge and hides the key input', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(ok({
|
||||
credentials: { OPENAI_API_KEY: { configured: true, source: 'env', writable: false } },
|
||||
})))
|
||||
fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
|
||||
await screen.findByText(content => content.includes(en.credentialFromEnv))
|
||||
expect(screen.queryByLabelText(en.keyInput)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders no badge while the credential domain fails, and keeps a failed post-save describe quiet', async () => {
|
||||
const { face, set } = await mountSection()
|
||||
face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
expect(screen.queryByText(en.credentialConfigured)).toBeNull()
|
||||
expect(screen.queryByText(en.credentialMissing)).toBeNull()
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
expect(key).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stores a credential value write-only and refreshes its badge', async () => {
|
||||
const { set, face } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
await waitFor(() => { expect(face.credentials.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect(key.value).toBe('')
|
||||
})
|
||||
|
||||
it('surfaces a shadowed credential write on the control', async () => {
|
||||
await mountSection({
|
||||
set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.keySave))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
const face = scriptedFace()
|
||||
face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never
|
||||
const controller = new ModelsSettingsStore(face.face as never)
|
||||
await controller.load()
|
||||
render(<ModelsSection injected={{
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face.face as never,
|
||||
t,
|
||||
}} />)
|
||||
expect(screen.getByText(/directory down/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
await waitFor(() => { expect(screen.queryByText(/directory down/)).toBeNull() })
|
||||
})
|
||||
|
||||
it('shows the read-only notice and disables mutations for a read-only provider', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.settings.describe.mockImplementation(() => Promise.resolve(ok({
|
||||
writable: false,
|
||||
namespaces: wireNamespaces(),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
await controller.load()
|
||||
cleanup()
|
||||
render(<ModelsSection injected={{
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
t,
|
||||
}} />)
|
||||
expect(screen.getByText(en.readOnly)).toBeTruthy()
|
||||
expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles the editor closed on a second edit click and on cancel', async () => {
|
||||
const { update } = await mountSection()
|
||||
const edit = screen.getAllByText(en.edit)[1] as HTMLElement
|
||||
fireEvent.click(edit)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.click(edit)
|
||||
expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
|
||||
fireEvent.click(edit)
|
||||
await screen.findByDisplayValue('https://proxy')
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores the placeholder option of the add select', async () => {
|
||||
await mountSection()
|
||||
fireEvent.change(screen.getByLabelText(en.add), { target: { value: '' } })
|
||||
expect(screen.queryByText(en.apply)).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a whole-section namespace (path []) as a direct patch', async () => {
|
||||
const { update } = await mountSection({
|
||||
update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
|
||||
})
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
await screen.findByLabelText(en.credentialRef)
|
||||
const label = screen.getByPlaceholderText<HTMLInputElement>(/label|Default/i) ?? undefined
|
||||
const labelInput = screen.getAllByRole('textbox').find(input =>
|
||||
(input as HTMLInputElement).type === 'text'
|
||||
&& input.closest('div')?.previousElementSibling?.textContent?.includes('label') === true)
|
||||
const target = labelInput ?? screen.getAllByRole('textbox').at(-1)
|
||||
fireEvent.change(target as Element, { target: { value: 'Mine' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
|
||||
const payload = update.mock.calls[0]?.[0] as { ns: string; patch: Record<string, unknown> }
|
||||
expect(payload.ns).toBe('llm-deepseek')
|
||||
expect(payload.patch['label']).toBe('Mine')
|
||||
expect(label ?? true).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects a section-level invalid draft before writing', async () => {
|
||||
const { update } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
await screen.findByLabelText(en.credentialRef)
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
// schemastery names the missing required field in its failure text.
|
||||
await screen.findByText(/required/)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads on first render of an idle controller', async () => {
|
||||
const { face } = scriptedFace()
|
||||
const controller = new ModelsSettingsStore(face as never)
|
||||
render(<ModelsSection injected={{
|
||||
controller,
|
||||
useSnapshot: bindSnapshotSelector(controller.store),
|
||||
api: face as never,
|
||||
t,
|
||||
}} />)
|
||||
await screen.findByText('DeepSeek')
|
||||
})
|
||||
|
||||
it('removes against a namespace with no user layer as an empty-section replace', async () => {
|
||||
const { face, replace, controller } = await mountSection()
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-deepseek')
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-deepseek', settingsPath: ['ghost-profile'] },
|
||||
namespace as NonNullable<typeof namespace>,
|
||||
)
|
||||
expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
|
||||
})
|
||||
|
||||
it('keeps the snapshot untouched when a removal write is refused', async () => {
|
||||
const { face, controller } = await mountSection({
|
||||
replace: vi.fn(() => Promise.resolve(fail('read-only'))),
|
||||
})
|
||||
const namespace = controller.store.getSnapshot().namespaces.get('llm-pi-ai')
|
||||
const before = controller.store.getSnapshot().rows
|
||||
await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{ settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] },
|
||||
namespace as NonNullable<typeof namespace>,
|
||||
)
|
||||
expect(controller.store.getSnapshot().rows).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('invariant companion', () => {
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
|
||||
it('the section content column is intentionally empty this phase', () => {
|
||||
expect(ModelsSection()).toBeNull()
|
||||
it('renders null until the shell injects the section dependencies', () => {
|
||||
expect(ModelsSection({})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSettingsStore } from '../src/client/store.ts'
|
||||
|
||||
let nextRpc = 0
|
||||
function ok<T>(value: T): RpcResponse<T> {
|
||||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } }
|
||||
}
|
||||
function fail<T>(message: string): RpcResponse<T> {
|
||||
return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code: 'internal', message, details: {} } } }
|
||||
}
|
||||
|
||||
const DIRECTORY = [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'ghost', displayName: 'Ghost', settingsNs: '', settingsPath: [], active: true },
|
||||
]
|
||||
|
||||
const NAMESPACES = [
|
||||
{
|
||||
ns: 'llm-deepseek',
|
||||
schema: {},
|
||||
value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
|
||||
base: { baseURL: 'https://base' },
|
||||
applies: 'live' as const,
|
||||
secrets: [{ path: ['apiKey'], set: false }],
|
||||
},
|
||||
{
|
||||
ns: 'llm-pi-ai',
|
||||
schema: {},
|
||||
value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
|
||||
user: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
},
|
||||
]
|
||||
|
||||
function api(overrides: {
|
||||
providers?: () => Promise<RpcResponse<{ providers: typeof DIRECTORY }>>
|
||||
describeSettings?: () => Promise<RpcResponse<{ writable: boolean; namespaces: typeof NAMESPACES }>>
|
||||
describeCredentials?: (refs: string[]) => Promise<RpcResponse<{ credentials: Record<string, unknown> }>>
|
||||
} = {}) {
|
||||
const seenRefs: string[][] = []
|
||||
const face = {
|
||||
llm: {
|
||||
providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))),
|
||||
models: () => Promise.resolve(ok({ groups: [], failures: [] })),
|
||||
},
|
||||
settings: {
|
||||
describe: overrides.describeSettings ?? (() => Promise.resolve(ok({ writable: true, namespaces: NAMESPACES }))),
|
||||
update: () => Promise.resolve(fail('unused')),
|
||||
replace: () => Promise.resolve(fail('unused')),
|
||||
},
|
||||
credentials: {
|
||||
describe: (payload: { refs: string[] }) => {
|
||||
seenRefs.push(payload.refs)
|
||||
return (overrides.describeCredentials ?? (refs => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(refs.map(ref => [ref, { configured: ref === 'OPENAI_API_KEY', writable: true }])),
|
||||
}))))(payload.refs)
|
||||
},
|
||||
set: () => Promise.resolve(ok({})),
|
||||
unset: () => Promise.resolve(ok({})),
|
||||
},
|
||||
}
|
||||
return { face: face as never, seenRefs }
|
||||
}
|
||||
|
||||
describe('ModelsSettingsStore', () => {
|
||||
it('joins rows with configured, removable, and credential state', async () => {
|
||||
const { face, seenRefs } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.writable).toBe(true)
|
||||
expect(seenRefs).toEqual([['DEEPSEEK_API_KEY', 'OPENAI_API_KEY']])
|
||||
const byProvider = new Map(state.rows.map(row => [row.entry.provider, row]))
|
||||
expect(byProvider.get('deepseek-official')).toMatchObject({
|
||||
configured: true,
|
||||
removable: false,
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
credential: { configured: false, writable: true },
|
||||
})
|
||||
expect(byProvider.get('openai')).toMatchObject({
|
||||
configured: true,
|
||||
removable: true,
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
credential: { configured: true },
|
||||
})
|
||||
expect(byProvider.get('anthropic')).toMatchObject({ configured: false, removable: false })
|
||||
expect(byProvider.get('anthropic')?.apiKeyEnv).toBeUndefined()
|
||||
expect(byProvider.get('ghost')).toMatchObject({ configured: false, removable: false })
|
||||
expect(state.namespaces.get('llm-pi-ai')?.ns).toBe('llm-pi-ai')
|
||||
})
|
||||
|
||||
it('degrades the credential badge, not the page, when the credential domain fails', async () => {
|
||||
const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) })
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.rows.every(row => row.credential === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces a directory failure and keeps the last good rows', async () => {
|
||||
const { face } = api()
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||||
const broken = api({ providers: () => Promise.resolve(fail('directory down')) })
|
||||
const failing = new ModelsSettingsStore(broken.face)
|
||||
await failing.load()
|
||||
expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' })
|
||||
// The first store's snapshot is untouched by the second's failure.
|
||||
expect(store.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('lets the newest load win over a stale slow response', async () => {
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let call = 0
|
||||
const { face } = api({
|
||||
providers: async () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
await gate
|
||||
return fail('stale slow failure')
|
||||
}
|
||||
return ok({ providers: DIRECTORY })
|
||||
},
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
const first = store.load()
|
||||
const second = store.load()
|
||||
release?.()
|
||||
await Promise.all([first, second])
|
||||
expect(store.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge joins', () => {
|
||||
it('treats a non-object profile as having no credential reference', async () => {
|
||||
const { face } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{
|
||||
ns: 'llm-pi-ai',
|
||||
schema: {},
|
||||
value: { providers: { weird: 'oops' } },
|
||||
applies: 'live' as const,
|
||||
secrets: [],
|
||||
}] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({
|
||||
providers: [
|
||||
{ provider: 'weird', displayName: 'weird', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'weird'], active: false },
|
||||
] as never,
|
||||
})),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
const state = store.store.getSnapshot()
|
||||
expect(state.rows[0]).toMatchObject({ configured: true, removable: false })
|
||||
expect(state.rows[0]?.apiKeyEnv).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips the credential describe entirely when no row names a reference', async () => {
|
||||
const { face, seenRefs } = api({
|
||||
describeSettings: () => Promise.resolve(ok({
|
||||
writable: true,
|
||||
namespaces: [{ ns: 'llm-pi-ai', schema: {}, value: { providers: {} }, applies: 'live' as const, secrets: [] }] as never,
|
||||
})),
|
||||
providers: () => Promise.resolve(ok({
|
||||
providers: [
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
] as never,
|
||||
})),
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(seenRefs).toEqual([])
|
||||
expect(store.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('surfaces a settings describe failure', async () => {
|
||||
const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) })
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' })
|
||||
})
|
||||
|
||||
it('stringifies a non-Error load failure', async () => {
|
||||
// The wire can surface non-Error throwables; the store must stringify them.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
const { face } = api({ providers: () => Promise.reject('plain refusal') })
|
||||
const store = new ModelsSettingsStore(face)
|
||||
await store.load()
|
||||
expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' })
|
||||
})
|
||||
|
||||
it('drops a stale successful response after a newer load finished', async () => {
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let call = 0
|
||||
const { face } = api({
|
||||
providers: async () => {
|
||||
call += 1
|
||||
if (call === 1) {
|
||||
await gate
|
||||
return ok({ providers: [] as never })
|
||||
}
|
||||
return ok({ providers: DIRECTORY })
|
||||
},
|
||||
})
|
||||
const store = new ModelsSettingsStore(face)
|
||||
const first = store.load()
|
||||
const second = store.load()
|
||||
await second
|
||||
release?.()
|
||||
await first
|
||||
// The stale empty directory never overwrote the newer join.
|
||||
expect(store.store.getSnapshot().rows).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,15 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../schema-form"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
|
||||
Generated
+9
@@ -1194,18 +1194,27 @@ importers:
|
||||
|
||||
packages/client/ui-models:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-schema-form':
|
||||
specifier: workspace:^
|
||||
version: link:../schema-form
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-web-react':
|
||||
specifier: workspace:^
|
||||
version: link:../web-react
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
|
||||
"@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
|
||||
"@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
|
||||
"@deepseek-ai/dsh-client-schema-form": ["./packages/client/schema-form/src"],
|
||||
"@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-web-react": ["./packages/client/web-react/src"],
|
||||
"@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"],
|
||||
"@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"],
|
||||
|
||||
Reference in New Issue
Block a user