feat(ui): add the image-recognition settings card

An image-recognition card in the Plugins settings page (endpoint + key),
cloned from the web-search card: baseURL via the settings section, the key
through the credentials domain.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 11:15:26 +08:00
parent 381c6510e9
commit b6b982dc87
4 changed files with 250 additions and 1 deletions
@@ -0,0 +1,61 @@
/**
* The image-recognition provider's card: its endpoint and the key — which is
* written through the credentials domain, never into the settings section, so
* the literal never rides a response.
*/
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { SecretField, ValueField } from './fields.tsx'
import { PluginCard } from './PluginCard.tsx'
import type { ImageRecognitionCardFace } from './image-recognition-card-controller.ts'
import type {} from './slot-contract.ts'
/** Props the renderer binds for the image-recognition card. */
export type ImageRecognitionCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.plugins'>
& InjectFace<ImageRecognitionCardFace>
/**
* Render the image-recognition card.
* @param props - locale copy, the card snapshot, and its form actions.
* @returns the card.
*/
export function ImageRecognitionCard(props: ImageRecognitionCardProps) {
const { t } = props
const state = props.useImageRecognitionCard(snapshot => snapshot)
const disabled = !state.writable
return (
<PluginCard
t={t}
titleKey="imageRecognitionTitle"
descriptionKey="imageRecognitionDescription"
state={state}
onSave={props.save}
onDiscard={props.discard}
>
<SecretField
id="plugin-config-image-recognition-key"
label={t('imageRecognitionApiKey')}
hint={t('imageRecognitionApiKeyHint')}
disabled={!state.apiKeyWritable}
text={state.apiKey.text}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('imageRecognitionApiKeySet') : t('imageRecognitionApiKeyUnset')}
onEdit={(text) => { props.edit('apiKey', text) }}
/>
<ValueField
id="plugin-config-image-recognition-endpoint"
label={t('imageRecognitionBaseUrl')}
hint={t('imageRecognitionBaseUrlHint')}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalidNumber')}
disabled={disabled}
{...state.baseURL}
onEdit={(text) => { props.edit('baseURL', text) }}
onReset={() => { props.resetField('baseURL') }}
/>
</PluginCard>
)
}
@@ -0,0 +1,155 @@
/**
* The image-recognition card's staged form over the `image-recognition-http`
* settings namespace.
*
* The key is the one control that does not live in the section: its literal
* never rides a response, so the card learns only whether one is configured
* and writes it through the credentials domain, addressed by the reference the
* section names. It is still staged with the rest of the form, so one save
* covers everything the card shows.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import {
CardForm, textField,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
/**
* Namespace of the HTTP image-recognition provider. Spelled here rather than
* imported: a client package must not depend on a Host package.
*/
export const IMAGE_RECOGNITION_NS = 'image-recognition-http'
/** Credential reference the provider resolves when the section names none. */
const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
/** Form field the credential control stages under. */
const API_KEY_FIELD = 'apiKey'
/** The provider fields this card edits. */
export interface ImageRecognitionSettings {
/** Credential reference naming the environment key. */
apiKeyEnv?: string
/** Provider endpoint; blank inherits the provider default. */
baseURL?: string
}
/** What the credentials domain last reported, and for which reference. */
interface CredentialState {
/** Reference this answer describes; a stale response for another one is dropped. */
ref: string
/** Whether any layer supplies a value for it. */
configured: boolean
/** Whether `credentials.set` can affect it; false disables the control. */
writable: boolean
}
/** What the image-recognition card renders. */
export interface ImageRecognitionCardState extends CardShell {
/** Provider endpoint. */
baseURL: CardFieldState
/** The staged credential, which starts blank on every load. */
apiKey: CardFieldState
/** Whether the Host reports a credential configured for the referenced key. */
apiKeyConfigured: boolean
/** Whether the credentials domain accepts a write for it; false disables the control. */
apiKeyWritable: boolean
}
/** The registration-side face the image-recognition card's slot entry injects. */
export interface ImageRecognitionCardFace extends CardActions {
hooks: {
/** Card snapshot bound by the renderer as useImageRecognitionCard. */
imageRecognitionCard: SnapshotStore<ImageRecognitionCardState>
}
}
/** Bridges the `image-recognition-http` scope and the credentials domain onto the card. */
export class ImageRecognitionCardController {
private readonly form: CardForm<ImageRecognitionSettings>
private readonly store: SnapshotStore<ImageRecognitionCardState>
private credential: CredentialState = { ref: '', configured: false, writable: true }
/**
* @param scope - the bound settings scope for the `image-recognition-http` namespace.
* @param api - wire face used for the credential the section references.
*/
constructor(
private readonly scope: SettingsScope<ImageRecognitionSettings>,
private readonly api: Pick<IApiClient, 'credentials'>,
) {
this.form = new CardForm(
scope,
[textField('baseURL')],
[{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
)
this.store = this.form.bind(() => this.projection())
scope.subscribe(() => { void this.readCredential() })
void this.readCredential()
}
private projection(): ImageRecognitionCardState {
return {
...this.form.shell(),
baseURL: this.form.field('baseURL'),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.credential.configured,
apiKeyWritable: this.credential.writable,
}
}
/** Ask the credentials domain about the reference the section currently names. */
private async readCredential(): Promise<void> {
const ref = refOf(this.scope.getSnapshot())
if (ref !== this.credential.ref) {
this.credential = { ref, configured: false, writable: true }
this.store.set(this.projection())
}
let response: Awaited<ReturnType<IApiClient['credentials']['describe']>>
try {
response = await this.api.credentials.describe({ refs: [ref] })
} catch (_credentialReadFailure) {
return
}
if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return
const view = response.result.value.credentials[ref]
const next: CredentialState = {
ref,
configured: view?.configured ?? false,
writable: view?.writable ?? true,
}
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
this.credential = next
this.store.set(this.projection())
}
/** Re-read after the Host reports a change to the reference this card watches. */
refreshCredential(ref: string): void {
if (ref !== this.credential.ref) return
void this.readCredential()
}
/** Build the face the card's slot registration injects. */
inject(): ImageRecognitionCardFace {
return { hooks: { imageRecognitionCard: this.store }, ...this.form.actions() }
}
/** Write the staged key, then re-read whether the Host now holds one. */
private async writeKey(value: string): Promise<boolean> {
try {
await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value })
} catch (_credentialWriteFailure) {
// Refusals surface through the re-read below.
}
await this.readCredential()
return this.credential.configured
}
}
/** The credential reference the section names, or the provider's default. */
function refOf(snapshot: SettingsScopeSnapshot<ImageRecognitionSettings>): string {
const declared = snapshot.value?.apiKeyEnv
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF
}
@@ -24,11 +24,13 @@ import { AgentLoopCard } from './AgentLoopCard.tsx'
import { BashCard } from './BashCard.tsx'
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
import { ImageRecognitionCard } from './ImageRecognitionCard.tsx'
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
import { IMAGE_RECOGNITION_NS, ImageRecognitionCardController } from './image-recognition-card-controller.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
import { en, zh } from './locales.ts'
@@ -42,6 +44,7 @@ export type {
} from './card-form.ts'
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts'
export type { BashCardFace, BashCardState } from './bash-card-controller.ts'
export type { ImageRecognitionCardFace, ImageRecognitionCardState } from './image-recognition-card-controller.ts'
export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts'
/** Dictionary namespace owned by this plugin. */
@@ -62,12 +65,16 @@ export function apply(ctx: ClientContext): void {
const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS }))
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api)
const imageRecognition = new ImageRecognitionCardController(ctx.settingsScope.bind({ namespace: IMAGE_RECOGNITION_NS }), api)
// The credential a card reports is not part of any settings section, so its
// scope publishes nothing when one is written. This is the only signal that
// a key written on another surface reached the Host.
ctx.effect(
() => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
() => ctx.remote.$on('credentials/updated', (ref) => {
webSearch.refreshCredential(ref)
imageRecognition.refreshCredential(ref)
}),
'ui-settings-plugins: credential invalidations',
)
@@ -154,5 +161,12 @@ export function apply(ctx: ClientContext): void {
locale: NS,
inject: () => webSearch.inject(),
}, WebSearchCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
id: 'image-recognition',
order: 30,
locale: NS,
inject: () => imageRecognition.inject(),
}, ImageRecognitionCard)
})
}
@@ -11,6 +11,9 @@ export type PluginsSettingsLocaleKey =
| 'webSearchTitle' | 'webSearchDescription'
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
| 'imageRecognitionTitle' | 'imageRecognitionDescription'
| 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset'
| 'imageRecognitionBaseUrl' | 'imageRecognitionBaseUrlHint'
/** English copy. */
export const en: Record<PluginsSettingsLocaleKey, string> = {
@@ -51,6 +54,14 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
webSearchBaseUrlHint: 'Leave blank to use the provider default.',
webSearchMaxUses: 'Max searches per request',
webSearchMaxUsesHint: 'How many times one request may search before it must answer.',
imageRecognitionTitle: 'Image recognition',
imageRecognitionDescription: 'The image-recognition vision provider.',
imageRecognitionApiKey: 'API key',
imageRecognitionApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
imageRecognitionApiKeySet: 'A key is configured.',
imageRecognitionApiKeyUnset: 'No key is configured; recognition is unavailable until one is.',
imageRecognitionBaseUrl: 'Endpoint',
imageRecognitionBaseUrlHint: 'Leave blank to use the provider default.',
}
/** Simplified Chinese copy. */
@@ -92,4 +103,12 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
webSearchMaxUses: '单次请求最多搜索次数',
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
imageRecognitionTitle: '图像识别',
imageRecognitionDescription: '图像识别的视觉提供方。',
imageRecognitionApiKey: 'API Key',
imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
imageRecognitionApiKeySet: '已配置密钥。',
imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。',
imageRecognitionBaseUrl: '接口地址',
imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。',
}