/** * The describe-image settings card: the vision endpoint (base URL, model, * key reference), the default instruction, and the call bounds. Registers * into the `web-ui.plugin.item` slot the Web Plugins group renders, * bound to the `describe-image` settings namespace through the family * settings bridge (or the official settings scope when the deployment * exposes the namespace directly). * @module @linxin666/dsh-tool-describe-image/client/DescribeImageSettingsCard */ import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { PluginSettingsCard, BooleanField, ChoiceField, ValueField } from './PluginSettingsCard.tsx' import { CardForm, booleanField, choiceField, numberField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts' import { fetchEndpointModels, testEndpointModel } from './model-probe.ts' import { NativeImageSection } from './NativeImageSection.tsx' import { t } from './locales.ts' import cardCss from './settings-card.module.css' import css from './probe.module.css' /** The describe-image fields this card edits (the namespace's full schema). */ export interface DescribeImageSettings { baseURL?: string model?: string apiKey?: string apiKeyEnv?: string defaultPrompt?: string maxBytes?: number maxOutputTokens?: number timeoutMs?: number apiStyle?: 'chat-completions' | 'responses' | 'anthropic-messages' rotationMode?: 'round-robin' | 'failover' retryNextOnFailure?: boolean renderImagePreview?: boolean interceptImageSend?: boolean } /** The probe button's live state between clicks. */ export interface ProbeState { /** idle before the first click, running while a request crosses the wire. */ status: 'idle' | 'running' /** Which action is running (or ran last), so the status line words itself. */ pending?: 'fetch' | 'test' /** Model ids the last successful listing returned, in listing order. */ models: string[] /** Round-trip milliseconds of the last successful model ping. */ latencyMs?: number /** The failure reason the last action surfaced; absent until one fails. */ error?: string } /** What the describe-image card renders. */ export interface DescribeImageSettingsCardState extends CardShell { baseURL: CardFieldState model: CardFieldState apiKey: CardFieldState apiKeyEnv: CardFieldState defaultPrompt: CardFieldState maxBytes: CardFieldState maxOutputTokens: CardFieldState timeoutMs: CardFieldState apiStyle: CardFieldState rotationMode: CardFieldState retryNextOnFailure: CardFieldState renderImagePreview: CardFieldState interceptImageSend: CardFieldState probe: ProbeState } /** The registration-side face the card's slot entry injects. */ export interface DescribeImageSettingsCardFace extends CardActions { /** List the endpoint's models against the card's current connection drafts. */ fetchModels: () => void /** Ping the selected model once and report its round-trip latency. */ testModel: () => void hooks: { /** Card snapshot bound by the renderer as useDescribeImageSettingsCard. */ describeImageSettingsCard: SnapshotStore } } /** Bridges the `describe-image` scope onto the card's staged form. */ export class DescribeImageSettingsCardController { private readonly form: CardForm private readonly store: SnapshotStore private probeState: ProbeState = { status: 'idle', models: [] } private disposed = false /** @param scope - the bound settings scope for the `describe-image` namespace. */ constructor(scope: SettingsScope) { this.form = new CardForm(scope, [ textField('baseURL'), textField('model'), choiceField('apiStyle', ['chat-completions', 'responses', 'anthropic-messages']), secretField('apiKey'), textField('apiKeyEnv'), choiceField('rotationMode', ['round-robin', 'failover']), booleanField('retryNextOnFailure'), textField('defaultPrompt'), numberField('maxBytes'), numberField('maxOutputTokens'), numberField('timeoutMs'), booleanField('renderImagePreview'), booleanField('interceptImageSend'), ]) this.store = this.form.bind(() => this.projection()) } /** * List the endpoint named by the card's current drafts. Drafts ride the * request so an unsaved endpoint can be verified before saving; the key * never crosses into the browser. A failed listing drops any stale list. */ fetchModels(): void { if (this.disposed || this.probeState.status === 'running') return const draft = { baseURL: this.form.field('baseURL').text, apiStyle: this.form.field('apiStyle').text, apiKey: this.form.field('apiKey').text, } this.probeState = { ...this.probeState, status: 'running', pending: 'fetch', error: undefined } this.publish() void fetchEndpointModels(draft).then((result) => { if (this.disposed) return this.probeState = result.ok ? { status: 'idle', pending: 'fetch', models: result.models } : { status: 'idle', pending: 'fetch', models: [], error: result.message } this.publish() }) } /** * Ping the selected model once: one minimal completion call whose * round-trip latency is the model's own first-response time. Hidden until * the model field carries a value; the listing stays while it runs. */ testModel(): void { if (this.disposed || this.probeState.status === 'running') return const model = this.form.field('model').text if (model.trim() === '') return const draft = { baseURL: this.form.field('baseURL').text, apiStyle: this.form.field('apiStyle').text, apiKey: this.form.field('apiKey').text, model, } this.probeState = { ...this.probeState, status: 'running', pending: 'test', latencyMs: undefined, error: undefined } this.publish() void testEndpointModel(draft).then((result) => { if (this.disposed) return this.probeState = result.ok ? { ...this.probeState, status: 'idle', pending: 'test', latencyMs: result.latencyMs } : { ...this.probeState, status: 'idle', pending: 'test', error: result.message } this.publish() }) } /** Re-emit the projection; a probe settling publishes outside scope changes. */ private publish(): void { this.store.set(this.projection()) } private projection(): DescribeImageSettingsCardState { return { ...this.form.shell(), baseURL: this.form.field('baseURL'), model: this.form.field('model'), apiStyle: this.form.field('apiStyle'), apiKey: this.form.field('apiKey'), apiKeyEnv: this.form.field('apiKeyEnv'), rotationMode: this.form.field('rotationMode'), retryNextOnFailure: this.form.field('retryNextOnFailure'), defaultPrompt: this.form.field('defaultPrompt'), maxBytes: this.form.field('maxBytes'), maxOutputTokens: this.form.field('maxOutputTokens'), timeoutMs: this.form.field('timeoutMs'), renderImagePreview: this.form.field('renderImagePreview'), interceptImageSend: this.form.field('interceptImageSend'), probe: this.probeState, } } /** * Build the face the card's slot registration injects. * @returns the card's snapshot and its form actions. */ inject(): DescribeImageSettingsCardFace { return { hooks: { describeImageSettingsCard: this.store }, ...this.form.actions(), fetchModels: () => { this.fetchModels() }, testModel: () => { this.testModel() }, } } /** * Release the card's scope subscription and bound stores; the slot * disposer calls this on teardown. A request still in flight settles into * nothing once disposed. */ dispose(): void { this.disposed = true this.form.dispose() } } /** Props the renderer binds for the describe-image card. */ export type DescribeImageSettingsCardProps = PropsRuntime<'web-ui.plugin.item'> & InjectFace /** * Render the describe-image card. * @param props - the card snapshot and its form actions. * @returns the card. */ export function DescribeImageSettingsCard(props: DescribeImageSettingsCardProps) { const state = props.useDescribeImageSettingsCard(snapshot => snapshot) const disabled = !state.writable const fieldProps = { overriddenLabel: t('settings.overridden'), resetLabel: t('settings.reset'), invalidLabel: t('settings.invalidNumber'), disabled, } return ( { props.edit('baseURL', text) }} onReset={() => { props.resetField('baseURL') }} />
{state.model.overridden ? {t('settings.overridden')} : null} {state.model.text.trim() !== '' ? ( ) : null}
{state.probe.models.length > 0 ? ( ) : ( { props.edit('model', event.target.value) }} /> )} {state.probe.status === 'running' ?

{t('probe.running')}

: null} {state.probe.status === 'idle' && state.probe.error !== undefined ?

{t('probe.error', { error: state.probe.error })}

: null} {state.probe.status === 'idle' && state.probe.error === undefined && state.probe.pending === 'test' && state.probe.latencyMs !== undefined ? (

{t('probe.success', { ms: String(state.probe.latencyMs) })}

) : null} {state.probe.status === 'idle' && state.probe.error === undefined && state.probe.pending === 'fetch' && state.probe.models.length > 0 ? (

{t('probe.fetched', { count: String(state.probe.models.length) })}

) : null} {state.probe.status === 'idle' && state.probe.error === undefined && state.probe.models.length === 0 ? (

{state.model.invalid ? t('settings.invalidNumber') : t('field.model.hint')}

) : null}
{ props.edit('apiStyle', text) }} onReset={() => { props.resetField('apiStyle') }} /> { props.edit('apiKey', text) }} onReset={() => { props.resetField('apiKey') }} /> { props.edit('apiKeyEnv', text) }} onReset={() => { props.resetField('apiKeyEnv') }} /> { props.edit('defaultPrompt', text) }} onReset={() => { props.resetField('defaultPrompt') }} /> { props.edit('maxBytes', text) }} onReset={() => { props.resetField('maxBytes') }} /> { props.edit('maxOutputTokens', text) }} onReset={() => { props.resetField('maxOutputTokens') }} /> { props.edit('timeoutMs', text) }} onReset={() => { props.resetField('timeoutMs') }} /> { props.edit('rotationMode', text) }} onReset={() => { props.resetField('rotationMode') }} /> { props.edit('retryNextOnFailure', text) }} onReset={() => { props.resetField('retryNextOnFailure') }} /> { props.edit('renderImagePreview', text) }} onReset={() => { props.resetField('renderImagePreview') }} /> { props.edit('interceptImageSend', text) }} onReset={() => { props.resetField('interceptImageSend') }} />
) }