diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index fb4985fef4..791bf3bc7d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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() diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 01a6691a4b..fd7858d60c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -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 diff --git a/packages/client/schema-form/src/index.ts b/packages/client/schema-form/src/index.ts index e83d180b5e..d01b55f872 100644 --- a/packages/client/schema-form/src/index.ts +++ b/packages/client/schema-form/src/index.ts @@ -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' diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts index 8415762940..17038f7c84 100644 --- a/packages/client/schema-form/src/model.ts +++ b/packages/client/schema-form/src/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 | 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). diff --git a/packages/client/schema-form/tests/model.spec.ts b/packages/client/schema-form/tests/model.spec.ts index 03dd6c8ef1..2b2eb5aeba 100644 --- a/packages/client/schema-form/tests/model.spec.ts +++ b/packages/client/schema-form/tests/model.spec.ts @@ -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() + }) +}) diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index cabeaf52e6..cbe8c75a57 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -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) + } + }) } /** diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index df8cba7c8b..9909ed3fb8 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -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", diff --git a/packages/client/ui-models/src/client/CredentialControl.tsx b/packages/client/ui-models/src/client/CredentialControl.tsx new file mode 100644 index 0000000000..bdd7f79d19 --- /dev/null +++ b/packages/client/ui-models/src/client/CredentialControl.tsx @@ -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(undefined) + const [keyDraft, setKeyDraft] = useState('') + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState(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 + ? ( + + {t('credentialConfigured')} + {state.source === 'env' ? ` · ${t('credentialFromEnv')}` : ''} + + ) + : {t('credentialMissing')} + + const storeKey = async (): Promise => { + /* 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 ( +
+
+ { + const next = event.target.value + if (next === '') context.clearValue() + else context.setValue(next) + }} + /> + {badge} +
+ {ref !== undefined && state?.writable !== false + ? ( +
+ { setKeyDraft(event.target.value) }} + /> + +
+ ) + : null} + {failure !== undefined ?

{failure}

: null} +
+ ) +} diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css new file mode 100644 index 0000000000..7b7d9fa1bf --- /dev/null +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -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); +} diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index ee33b916cb..d9b9580de9 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -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 + /** Wire faces the editor and credential control write through. */ + api: Pick + /** 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, + controller: ModelsSettingsStore, + target: { settingsNs: string; settingsPath: readonly string[] }, + namespace: SettingsNamespaceView, +): Promise { + const user = structuredClone((namespace.user ?? {}) as Record) + 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 ( + + {row.entry.active + ? {t('active')} + : {t('dormant')}} + {row.credential !== undefined && !row.credential.configured + ? {t('keyMissing')} + : null} + + ) +} + +/** + * 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 +} + +function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { + const { controller, api, t } = injected + const state = injected.useSnapshot(snapshot => snapshot) + const [editing, setEditing] = useState(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 ( +
+

{`${t('loadFailed')}: ${errorText}`}

+ +
+ ) + } + + 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 ( +
+

{t('title')}

+

{t('intro')}

+ {!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} +
    + {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 ( +
  • +
    + {row.entry.displayName} + + + + {row.removable + ? ( + + ) + : null} + +
    + {open + ? ( + + ) + : null} +
  • + ) + })} +
+
+ {addTarget !== undefined && addNamespace !== undefined + ? ( + + ) + : ( + + )} +
+
+ ) } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx new file mode 100644 index 0000000000..109d1429c4 --- /dev/null +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -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 + /** 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 { + const subtree = getPath(namespace.user, path) + if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {} + return structuredClone(subtree) as Record +} + +/** 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))) return true + if (removedAny(value, (after as Record)[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>(() => draftAt(namespace, settingsPath)) + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState(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 => { + 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), [...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

{`${props.provider}: unresolvable settings path`}

+ } + + return ( +
+
+ {props.provider} +
+ { + if (context.role !== 'credential-ref') return undefined + return + }} + /> + {failure !== undefined ?

{failure}

: null} +
+ + +
+
+ ) +} diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 5abcb65bcf..de260dec88 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -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). diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts new file mode 100644 index 0000000000..da525dcb5e --- /dev/null +++ b/packages/client/ui-models/src/client/locales.ts @@ -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: '该字段没有对应表单控件;请直接编辑设置文档。', +} diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts new file mode 100644 index 0000000000..13b1d611df --- /dev/null +++ b/packages/client/ui-models/src/client/store.ts @@ -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 +} + +/** 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 = createSnapshotStore({ + 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) {} + + /** + * 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 { + 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 = {} + 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 + }) + } +} diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 1842000675..7b05930d98 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -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) + }) +}) diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx new file mode 100644 index 0000000000..b501532773 --- /dev/null +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -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(value: T): RpcResponse { + return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } +} +function fail(message: string, code = 'settings-rejected'): RpcResponse { + return { + rpcId: `r-${nextRpc++}` as never, + result: { ok: false, error: { code, message, details: { ns: 'x' } } as never }, + } +} + +function scriptedFace(overrides: { + update?: ReturnType + replace?: ReturnType + set?: ReturnType +} = {}) { + 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[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() + 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(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(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(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(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(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(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(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() + 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() + expect(screen.getByText(en.readOnly)).toBeTruthy() + expect(screen.getAllByText(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(/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 } + 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() + 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[0], + controller, + { settingsNs: 'llm-deepseek', settingsPath: ['ghost-profile'] }, + namespace as NonNullable, + ) + 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[0], + controller, + { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, + namespace as NonNullable, + ) + expect(controller.store.getSnapshot().rows).toBe(before) + }) +}) diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts index 05fb52ee1b..8f9622b599 100644 --- a/packages/client/ui-models/tests/invariant.spec.ts +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -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() }) }) diff --git a/packages/client/ui-models/tests/store.spec.ts b/packages/client/ui-models/tests/store.spec.ts new file mode 100644 index 0000000000..eadeb0d913 --- /dev/null +++ b/packages/client/ui-models/tests/store.spec.ts @@ -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(value: T): RpcResponse { + return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } +} +function fail(message: string): RpcResponse { + 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> + describeSettings?: () => Promise> + describeCredentials?: (refs: string[]) => Promise }>> +} = {}) { + 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((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((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) + }) +}) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json index dde94c20af..7fda5bbb04 100644 --- a/packages/client/ui-models/tsconfig.json +++ b/packages/client/ui-models/tsconfig.json @@ -17,6 +17,15 @@ { "path": "../runtime" }, + { + "path": "../connection" + }, + { + "path": "../schema-form" + }, + { + "path": "../web-react" + }, { "path": "../ui-settings" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0611cab2c3..ef17048745 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ffb5618d7..b78e5d5b1b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],