dsh-vision-recognizer
Advanced tools
+45
-1
@@ -48,2 +48,6 @@ window.__ModuleLoader__.load({ id: "dsh-vision-recognizer", factory: (require) => { | ||
| saved: '已保存,立即生效', | ||
| test: '测试', | ||
| testing: '测试中…', | ||
| testOk: '连接正常', | ||
| testFailed: '测试失败', | ||
| saveFailed: '保存失败', | ||
@@ -81,2 +85,6 @@ loadFailed: '配置加载失败,请稍后重试', | ||
| saved: 'Saved — takes effect immediately', | ||
| test: 'Test', | ||
| testing: 'Testing…', | ||
| testOk: 'Connection OK', | ||
| testFailed: 'Test failed', | ||
| saveFailed: 'Save failed', | ||
@@ -106,2 +114,3 @@ loadFailed: 'Failed to load configuration', | ||
| .vrg-btn:disabled{opacity:.6;cursor:default} | ||
| .vrg-btn-ghost{background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-primary,#1f2328)} | ||
| .vrg-status{font-size:12px} | ||
@@ -136,2 +145,3 @@ .vrg-status.ok{color:var(--dsw-alias-state-success-primary,#16a34a)} | ||
| const [saving, setSaving] = useState(false) | ||
| const [testing, setTesting] = useState(false) | ||
| const [status, setStatus] = useState(null) | ||
@@ -172,2 +182,32 @@ | ||
| const runTest = useCallback(() => { | ||
| if (form === null || testing) return | ||
| setTesting(true) | ||
| setStatus(null) | ||
| fetch('/dsh-vision-recognizer/test', { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| provider: form.provider, | ||
| apiKey: form.apiKey, | ||
| model: form.model, | ||
| baseURL: form.baseURL, | ||
| }), | ||
| }) | ||
| .then((res) => res.text().then((text) => { | ||
| let body = {} | ||
| try { body = text ? JSON.parse(text) : {} } catch { body = { error: text.slice(0, 200) } } | ||
| return body | ||
| })) | ||
| .then((body) => { | ||
| if (body.ok) { | ||
| setStatus({ kind: 'ok', message: t('testOk') + (typeof body.durationMs === 'number' ? ' · ' + body.durationMs + 'ms' : '') }) | ||
| } else { | ||
| setStatus({ kind: 'err', message: t('testFailed') + (body.error ? ': ' + body.error : '') }) | ||
| } | ||
| }) | ||
| .catch((error) => setStatus({ kind: 'err', message: t('testFailed') + ': ' + String(error) })) | ||
| .finally(() => setTesting(false)) | ||
| }, [form, testing, t]) | ||
| const save = useCallback(() => { | ||
@@ -206,2 +246,5 @@ if (form === null || saving) return | ||
| setStatus({ kind: 'ok', message: t('saved') }) | ||
| // Auto-test the just-saved config so the user immediately sees | ||
| // whether the provider / key / model actually works. | ||
| runTest() | ||
| } else { | ||
@@ -213,3 +256,3 @@ setStatus({ kind: 'err', message: t('saveFailed') + (body.error ? ': ' + body.error : '') }) | ||
| .finally(() => setSaving(false)) | ||
| }, [form, saving, t]) | ||
| }, [form, saving, t, runTest]) | ||
@@ -302,2 +345,3 @@ if (loadError) return h('p', { className: 'vrg-intro' }, t('loadFailed')) | ||
| h('button', { className: 'vrg-btn', disabled: saving, onClick: save }, saving ? t('saving') : t('save')), | ||
| h('button', { className: 'vrg-btn vrg-btn-ghost', disabled: testing, onClick: runTest }, testing ? t('testing') : t('test')), | ||
| status !== null && h('span', { className: 'vrg-status ' + status.kind }, status.message))) | ||
@@ -304,0 +348,0 @@ } |
+33
-8
@@ -19,2 +19,7 @@ /** | ||
| /** Short prompt used by the API-key test request. */ | ||
| export const TEST_PROMPT = 'Reply with exactly: OK'; | ||
| /** A 32x32 solid-color PNG used by the API-key test (valid for every vision model, cheap). */ | ||
| export const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAL0lEQVR4nO3OIQEAAAgDMBrShPpIiHEzMb/q2UsqAQEBAQEBAQEBAQEBAQGBdOABt9TMl+gurJYAAAAASUVORK5CYII='; | ||
| /** The transcription prompt: a complete, faithful text rendition for a text-only recipient. */ | ||
@@ -73,2 +78,5 @@ export const TRANSCRIBE_PROMPT = `You are an image-to-text transcription service. The recipient is a text-only LLM that cannot see the image. Produce a faithful, complete text rendition covering: | ||
| } | ||
| if (status === 400 && /image|invalid/i.test(text) && /length|width|dimension|size|larger than|smaller than|resolution/i.test(text)) { | ||
| return { kind: 'image_dimensions', hint: 'the image does not meet the model\'s dimension requirements — it is probably too small (each side must usually be larger than ~10px); use a normal screenshot or resize the image' }; | ||
| } | ||
| if (status === 400 && /context|length|too (large|long)|token/i.test(text)) { | ||
@@ -118,3 +126,3 @@ return { kind: 'context_too_large', hint: 'input is too large for this model — try a smaller image or a longer-context model' }; | ||
| /** Build the OpenAI-compatible request for a transcription. */ | ||
| export function buildOpenAIRequest(attempt, base64, mediaType) { | ||
| export function buildOpenAIRequest(attempt, base64, mediaType, prompt = TRANSCRIBE_PROMPT) { | ||
| return { | ||
@@ -133,3 +141,3 @@ url: `${attempt.baseURL.replace(/\/+$/, '')}/chat/completions`, | ||
| { type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64}` } }, | ||
| { type: 'text', text: TRANSCRIBE_PROMPT }, | ||
| { type: 'text', text: prompt }, | ||
| ], | ||
@@ -142,3 +150,3 @@ }], | ||
| /** Build the Anthropic Messages request for a transcription. */ | ||
| export function buildAnthropicRequest(attempt, base64, mediaType) { | ||
| export function buildAnthropicRequest(attempt, base64, mediaType, prompt = TRANSCRIBE_PROMPT) { | ||
| return { | ||
@@ -159,3 +167,3 @@ url: `${attempt.baseURL.replace(/\/+$/, '')}/messages`, | ||
| { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64 } }, | ||
| { type: 'text', text: TRANSCRIBE_PROMPT }, | ||
| { type: 'text', text: prompt }, | ||
| ], | ||
@@ -188,3 +196,3 @@ }], | ||
| /** Send the transcription request for already-read image bytes; returns the text. */ | ||
| export async function transcribeRequest(attempt, data, mediaType, signal) { | ||
| export async function transcribeRequest(attempt, data, mediaType, signal, prompt = TRANSCRIBE_PROMPT) { | ||
| const base64 = Buffer.from(data).toString('base64'); | ||
@@ -196,3 +204,3 @@ const effectiveTimeout = isLocalBaseURL(attempt.baseURL) | ||
| const parse = attempt.protocol === PROTOCOL_ANTHROPIC ? parseAnthropicResponse : parseOpenAIResponse; | ||
| const request = build(attempt, base64, mediaType); | ||
| const request = build(attempt, base64, mediaType, prompt); | ||
| const post = () => fetch(request.url, { | ||
@@ -232,2 +240,20 @@ method: 'POST', | ||
| /** | ||
| * Verify a provider connection: sends a tiny valid image with a short prompt | ||
| * through the configured protocol and throws on any failure, so the Settings | ||
| * UI can report exactly why the key/endpoint/model does not work. | ||
| * @param attempt - resolved attempt (provider/model/baseURL/apiKey). | ||
| * @returns the resolved attempt on success. | ||
| */ | ||
| export async function testProvider(attempt) { | ||
| const apiKey = resolveApiKey(attempt); | ||
| if (apiKey === '' && !isLocalBaseURL(attempt.baseURL)) { | ||
| throw new Error('no API key configured — enter the key in Settings → Plugins → 识图, or export the provider env var'); | ||
| } | ||
| const effective = { ...attempt, apiKey }; | ||
| const data = Buffer.from(TEST_IMAGE_BASE64, 'base64'); | ||
| await transcribeRequest(effective, data, 'image/png', undefined, TEST_PROMPT); | ||
| return effective; | ||
| } | ||
| /** | ||
| * Fully resolve one config entry (primary or fallback) into an attempt object. | ||
@@ -237,4 +263,3 @@ * @param entry - {provider?, model?, baseURL?, apiKey?} plus shared defaults. | ||
| */ | ||
| export function resolveAttempt(entry, shared) { | ||
| const preset = providerById(entry.provider ?? 'custom'); | ||
| export function resolveAttempt(entry, shared) { const preset = providerById(entry.provider ?? 'custom'); | ||
| const baseURL = (entry.baseURL && entry.baseURL !== '') ? entry.baseURL : preset.baseURL; | ||
@@ -241,0 +266,0 @@ const model = (entry.model && entry.model !== '') ? entry.model : preset.defaultModel; |
+69
-26
@@ -157,17 +157,47 @@ /** | ||
| // Single exact route; the handler dispatches by HTTP method. The webserver | ||
| // rejects duplicate (kind, path) registrations, so registering GET and POST | ||
| // as two routes would throw and leave the POST handler dead. | ||
| return hostCtx.webServer.register({ | ||
| kind: 'exact', | ||
| path: '/dsh-vision-recognizer/config', | ||
| handler: async (request, response) => { | ||
| if (request.method === 'GET') { | ||
| sendJson(response, 200, { | ||
| providers: providerCatalog(), | ||
| config: redactedConfig(state), | ||
| }); | ||
| return; | ||
| } | ||
| if (request.method === 'POST') { | ||
| // The config route is a single exact route whose handler dispatches by HTTP | ||
| // method — the webserver rejects duplicate (kind, path) registrations, so | ||
| // registering GET and POST as two routes would throw and leave POST dead. | ||
| // The test route is a separate path and may be its own registration. | ||
| const disposers = [ | ||
| hostCtx.webServer.register({ | ||
| kind: 'exact', | ||
| path: '/dsh-vision-recognizer/config', | ||
| handler: async (request, response) => { | ||
| if (request.method === 'GET') { | ||
| sendJson(response, 200, { | ||
| providers: providerCatalog(), | ||
| config: redactedConfig(state), | ||
| }); | ||
| return; | ||
| } | ||
| if (request.method === 'POST') { | ||
| if (!sameOrigin(request)) { | ||
| sendJson(response, 403, { error: 'untrusted origin' }); | ||
| return; | ||
| } | ||
| try { | ||
| const body = await readJsonBody(request); | ||
| const patch = core.sanitizePatch(body); | ||
| const next = persistConfig({ ...state.config, ...patch }); | ||
| state.config = next; | ||
| sendJson(response, 200, { ok: true, config: redactedConfig(state) }); | ||
| } catch (error) { | ||
| sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); | ||
| } | ||
| return; | ||
| } | ||
| response.writeHead(405, { allow: 'GET, POST' }); | ||
| response.end(); | ||
| }, | ||
| }), | ||
| hostCtx.webServer.register({ | ||
| kind: 'exact', | ||
| path: '/dsh-vision-recognizer/test', | ||
| handler: async (request, response) => { | ||
| if (request.method !== 'POST') { | ||
| response.writeHead(405, { allow: 'POST' }); | ||
| response.end(); | ||
| return; | ||
| } | ||
| if (!sameOrigin(request)) { | ||
@@ -179,15 +209,28 @@ sendJson(response, 403, { error: 'untrusted origin' }); | ||
| const body = await readJsonBody(request); | ||
| const patch = core.sanitizePatch(body); | ||
| const next = persistConfig({ ...state.config, ...patch }); | ||
| state.config = next; | ||
| sendJson(response, 200, { ok: true, config: redactedConfig(state) }); | ||
| const c = state.config; | ||
| const candidate = { | ||
| provider: typeof body.provider === 'string' && body.provider !== '' ? body.provider : c.provider, | ||
| model: typeof body.model === 'string' ? body.model : c.model, | ||
| baseURL: typeof body.baseURL === 'string' ? body.baseURL : c.baseURL, | ||
| apiKey: typeof body.apiKey === 'string' && body.apiKey !== '' ? body.apiKey : c.apiKey, | ||
| }; | ||
| const shared = { maxTokens: 32, timeoutMs: Math.min(c.timeoutMs ?? DEFAULT_TIMEOUT_MS, 30_000) }; | ||
| const attempt = core.resolveAttempt(candidate, shared); | ||
| const started = Date.now(); | ||
| await core.testProvider(attempt); | ||
| sendJson(response, 200, { | ||
| ok: true, | ||
| durationMs: Date.now() - started, | ||
| model: attempt.model, | ||
| baseURL: attempt.baseURL, | ||
| }); | ||
| } catch (error) { | ||
| sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); | ||
| sendJson(response, 200, { ok: false, error: error instanceof Error ? error.message : String(error) }); | ||
| } | ||
| return; | ||
| } | ||
| response.writeHead(405, { allow: 'GET, POST' }); | ||
| response.end(); | ||
| }, | ||
| }); | ||
| }, | ||
| }), | ||
| ]; | ||
| return () => { | ||
| for (const dispose of disposers) dispose(); | ||
| }; | ||
| } | ||
@@ -194,0 +237,0 @@ |
+1
-1
| { | ||
| "name": "dsh-vision-recognizer", | ||
| "version": "0.1.1", | ||
| "version": "0.1.2", | ||
| "description": "DeepSeek Harness image recognition plugin: keep DeepSeek as the conversation brain while a configurable vision model (15+ providers, OpenAI-compatible + Anthropic) transcribes attached images. Configurable from Settings → Plugins.", | ||
@@ -5,0 +5,0 @@ "private": false, |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
74696
8.15%1189
10.09%5
25%