diff --git a/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx b/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx new file mode 100644 index 0000000000..609075f240 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/ImageRecognitionCard.tsx @@ -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 + +/** + * 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 ( + + { props.edit('apiKey', text) }} + /> + { props.edit('baseURL', text) }} + onReset={() => { props.resetField('baseURL') }} + /> + + ) +} diff --git a/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts b/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts new file mode 100644 index 0000000000..68faf3b5af --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/image-recognition-card-controller.ts @@ -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 + } +} + +/** Bridges the `image-recognition-http` scope and the credentials domain onto the card. */ +export class ImageRecognitionCardController { + private readonly form: CardForm + private readonly store: SnapshotStore + 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, + private readonly api: Pick, + ) { + 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 { + 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> + 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 { + 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): string { + const declared = snapshot.value?.apiKeyEnv + return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF +} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index dd0b80d89a..f511f89d74 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -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) }) } diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 1478e39997..a547b7b10d 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -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 = { @@ -51,6 +54,14 @@ export const en: Record = { 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 = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + imageRecognitionTitle: '图像识别', + imageRecognitionDescription: '图像识别的视觉提供方。', + imageRecognitionApiKey: 'API Key', + imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', + imageRecognitionApiKeySet: '已配置密钥。', + imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。', + imageRecognitionBaseUrl: '接口地址', + imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。', }