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:
@@ -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: '留空则使用提供方默认地址。',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user