fix(image-recognition): keep the vision key on its own ref; add clear-key

Image recognition and the chat model shared DEEPSEEK_API_KEY: the
image-recognition bundle defaulted apiKeyEnv to the model key, so saving one
overwrote the other. Point the bundle at IMAGE_RECOGNITION_API_KEY and guard
both the provider and the settings card against a stale model ref, so vision
never reads or writes the chat key. Also add a clear-key button, default the
model to qwen3-vl-flash on the DashScope compatible-mode endpoint, and send
file images as base64 with a normalized base URL.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 23:01:15 +08:00
parent 6890e87021
commit 768bcd4356
12 changed files with 237 additions and 15 deletions
@@ -42,6 +42,8 @@ export function ImageRecognitionCard(props: ImageRecognitionCardProps) {
text={state.apiKey.text}
configured={state.apiKeyConfigured}
stateLabel={state.apiKeyConfigured ? t('imageRecognitionApiKeySet') : t('imageRecognitionApiKeyUnset')}
clearLabel={t('imageRecognitionClearKey')}
onClear={props.clearApiKey}
onEdit={(text) => { props.edit('apiKey', text) }}
/>
<ValueField
@@ -17,6 +17,24 @@
gap: 8px;
}
.clear {
flex: none;
padding: 1px 6px;
font-size: 11px;
line-height: 1.4;
background: none;
border: 1px solid currentColor;
border-color: var(--dsw-alias-border-l1);
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.clear:hover {
color: var(--dsw-alias-danger-text);
border-color: currentColor;
}
.label {
flex: 1;
min-width: 0;
@@ -99,10 +99,21 @@ export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 't
configured: boolean
/** Copy describing the configured state. */
stateLabel: string
/** Optional small clear button to the left of the label; deletes the credential. */
clearLabel?: string
/** Called when the clear button is pressed. */
onClear?: () => void
}) {
return (
<div className={css.field}>
<div className={css.head}>
{props.onClear !== undefined
? (
<button type="button" className={css.clear} onClick={props.onClear} aria-label={props.clearLabel}>
{props.clearLabel}
</button>
)
: null}
<label className={css.label} htmlFor={props.id}>{props.label}</label>
<span className={css.badges}>
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
@@ -23,7 +23,14 @@ import {
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'
const DEFAULT_API_KEY_REF = 'IMAGE_RECOGNITION_API_KEY'
/**
* The main chat model's credential references. Image recognition must never
* resolve through these, or saving its key would overwrite the model's (and vice
* versa). A stale section that declares one of them is treated as unset.
*/
const MODEL_API_KEY_REFS = new Set(['DEEPSEEK_API_KEY', 'DEEPSEEK_OFFICIAL_API_KEY'])
/** Form field the credential control stages under. */
const API_KEY_FIELD = 'apiKey'
@@ -64,6 +71,8 @@ export interface ImageRecognitionCardState extends CardShell {
/** The registration-side face the image-recognition card's slot entry injects. */
export interface ImageRecognitionCardFace extends CardActions {
/** Delete the configured image-recognition credential, leaving the section empty. */
clearApiKey: () => void
hooks: {
/** Card snapshot bound by the renderer as useImageRecognitionCard. */
imageRecognitionCard: SnapshotStore<ImageRecognitionCardState>
@@ -138,7 +147,11 @@ export class ImageRecognitionCardController {
/** Build the face the card's slot registration injects. */
inject(): ImageRecognitionCardFace {
return { hooks: { imageRecognitionCard: this.store }, ...this.form.actions() }
return {
hooks: { imageRecognitionCard: this.store },
clearApiKey: () => { void this.clearKey() },
...this.form.actions(),
}
}
/** Write the staged key, then re-read whether the Host now holds one. */
@@ -151,10 +164,22 @@ export class ImageRecognitionCardController {
await this.readCredential()
return this.credential.configured
}
/** Delete the referenced credential, leaving the section empty (no vision model used). */
private async clearKey(): Promise<void> {
try {
await this.api.credentials.unset({ ref: refOf(this.scope.getSnapshot()) })
} catch (_credentialUnsetFailure) {
// Refusals surface through the re-read below.
}
await this.readCredential()
}
}
/** 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
// A declared model ref would conflate this card with the chat model; ignore it.
if (declared === undefined || declared.length === 0 || MODEL_API_KEY_REFS.has(declared)) return DEFAULT_API_KEY_REF
return declared
}
@@ -12,7 +12,7 @@ export type PluginsSettingsLocaleKey =
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
| 'imageRecognitionTitle' | 'imageRecognitionDescription'
| 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset'
| 'imageRecognitionApiKey' | 'imageRecognitionApiKeyHint' | 'imageRecognitionApiKeySet' | 'imageRecognitionApiKeyUnset' | 'imageRecognitionClearKey'
| 'imageRecognitionBaseUrl' | 'imageRecognitionBaseUrlHint'
| 'imageRecognitionModel' | 'imageRecognitionModelHint'
@@ -61,6 +61,7 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
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.',
imageRecognitionClearKey: 'Clear key',
imageRecognitionBaseUrl: 'Endpoint',
imageRecognitionBaseUrlHint: 'Leave blank to use the provider default.',
imageRecognitionModel: 'Model',
@@ -106,12 +107,13 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
webSearchMaxUses: '单次请求最多搜索次数',
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
imageRecognitionTitle: '图像识别(官方之外的新增)',
imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力',
imageRecognitionTitle: '图像识别',
imageRecognitionDescription: '图像识别的视觉提供方。为deepSeek补充视觉能力',
imageRecognitionApiKey: 'API Key',
imageRecognitionApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
imageRecognitionApiKeySet: '已配置密钥。',
imageRecognitionApiKeyUnset: '未配置密钥;配置之前图像识别不可用。',
imageRecognitionClearKey: '清除密钥',
imageRecognitionBaseUrl: '接口地址',
imageRecognitionBaseUrlHint: '留空则使用提供方默认地址。',
imageRecognitionModel: '模型',
@@ -116,9 +116,11 @@ describe('ui-settings-plugins apply', () => {
// A key written on another surface changes no settings section, so this
// event is the only thing that reaches the card.
ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
ctx.remote.$dispatch('credentials/updated', ['IMAGE_RECOGNITION_API_KEY'])
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(2) })
// Only the image-recognition card watches this ref now (web search uses
// DEEPSEEK_API_KEY), so one re-read fires.
await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
})
it('ignores a credential change for a reference no card watches', async () => {
@@ -15,17 +15,18 @@ afterEach(cleanup)
const t = (key: string): string => key
function renderCard() {
function renderCard(value: ImageRecognitionSettings = {}) {
const host = stubSettingsScope<ImageRecognitionSettings>()
const credentials = {
describe: vi.fn(() => Promise.resolve({
rpcId: 'c' as never,
result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: false, writable: true } } } },
result: { ok: true as const, value: { credentials: { IMAGE_RECOGNITION_API_KEY: { configured: false, writable: true } } } },
})),
set: vi.fn(),
unset: vi.fn(),
}
const controller = new ImageRecognitionCardController(host.scope, credentials as never)
host.publish({ status: 'ready', writable: true, value: {}, user: {} })
const controller = new ImageRecognitionCardController(host.scope, { credentials })
host.publish({ status: 'ready', writable: true, value, user: {} })
const face = controller.inject()
render(
<ImageRecognitionCard
@@ -35,6 +36,7 @@ function renderCard() {
discard={face.discard}
edit={face.edit}
resetField={face.resetField}
clearApiKey={face.clearApiKey}
useSessions={() => [] as never}
useWorkspaces={() => [] as never}
/>,
@@ -52,4 +54,16 @@ describe('ImageRecognitionCard', () => {
expect(screen.getByLabelText('imageRecognitionBaseUrl')).toBeTruthy()
expect(screen.getByLabelText('imageRecognitionModel')).toBeTruthy()
})
it('clears the configured credential through the clear-key button', () => {
const { credentials } = renderCard()
fireEvent.click(screen.getByRole('button', { name: 'imageRecognitionClearKey' }))
expect(credentials.unset).toHaveBeenCalledWith({ ref: 'IMAGE_RECOGNITION_API_KEY' })
})
it('ignores a stale model apiKeyEnv so the image-recognition key never overwrites the chat key', () => {
const { credentials } = renderCard({ apiKeyEnv: 'DEEPSEEK_API_KEY' })
fireEvent.click(screen.getByRole('button', { name: 'imageRecognitionClearKey' }))
expect(credentials.unset).toHaveBeenCalledWith({ ref: 'IMAGE_RECOGNITION_API_KEY' })
})
})