openpencil/apps/web/server/api/ai/validate.ts
Fini 84fc0ffddb fix(ai): explicit skipped reason for builtin providers in vision validate
Why: builtin providers (MiniMax / DeepSeek / Bailian / Ark) currently
fall through to the generic "Missing or unsupported provider" error in
/api/ai/validate. The post-generation loop catches that as a hard
provider error and logs "[error] Analysis skipped (timeout or provider
error)" — which reads like a config bug to the user even though the
real reason is "this provider's models are text-only, vision validation
isn't useful here even if we did proxy it".

What: branch on body.provider === 'builtin' before the generic error
and return { skipped: true, error: '<explanatory message>' }. The
client design-validation.ts already short-circuits on `data.skipped`
so the loop now logs the clearer message instead. No behavior change
for the four supported providers; no new wire fields.
2026-05-09 21:20:00 +08:00

293 lines
10 KiB
TypeScript

import { defineEventHandler, readBody, setResponseHeaders } from 'h3';
import { resolveClaudeCli } from '../../utils/resolve-claude-cli';
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env';
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runCodexExec } from '../../utils/codex-client';
interface ValidateBody {
system: string;
message: string;
imageBase64: string;
model?: string;
provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini';
}
/**
* Vision-based validation endpoint.
* Accepts a base64 PNG screenshot and a text prompt, sends multimodal
* content blocks for analysis via Agent SDK.
*
* Saves screenshot to temp file, asks Claude Code to read it via its
* built-in Read tool.
*/
export default defineEventHandler(async (event) => {
const body = await readBody<ValidateBody>(event);
if (!body?.system || !body?.message || !body?.imageBase64) {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
return { error: 'Missing required fields: system, message, imageBase64' };
}
if (!body.model?.trim()) {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
return { error: 'Missing model. Model fallback is disabled.' };
}
try {
if (body.provider === 'anthropic') {
return await validateViaAgentSDK(body, body.model);
}
if (body.provider === 'openai') {
return await validateViaCodex(body, body.model);
}
if (body.provider === 'opencode') {
return await validateViaOpenCode(body, body.model);
}
if (body.provider === 'gemini') {
return await validateViaGemini(body, body.model);
}
// Builtin providers (MiniMax / DeepSeek / Bailian / Ark / etc) are
// configured client-side and don't have vision support wired through
// here yet. Most of the configured models in this category are text-
// only anyway (MiniMax-M2.7, qwen3-coder-plus, ark-code-latest,
// deepseek-v4-pro), so a vision call would fail at the upstream API
// even if we did proxy it. Surface a clear `skipped` outcome so the
// post-generation loop logs "[error] Analysis skipped (provider has
// no vision support)" instead of the generic "unsupported provider"
// string — that one looks like a config bug to the user.
if (body.provider === 'builtin') {
return {
skipped: true,
error:
'Vision validation is not supported for builtin providers (text-only models). ' +
'Connect Claude Code, Codex, OpenCode, or Gemini CLI for the vision self-check loop.',
};
}
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { error: message };
}
});
function toImageBase64(data: string): string {
const dataUrlPrefix = 'data:image/png;base64,';
return data.startsWith(dataUrlPrefix) ? data.slice(dataUrlPrefix.length) : data;
}
async function withTempImageFile<T>(
imageBase64: string,
run: (tempPath: string) => Promise<T>,
insideProject = false,
): Promise<T> {
let tempDir: string;
if (insideProject) {
// Save inside the project directory so Claude Code Agent SDK (plan mode)
// can read the file — it restricts reads to the project directory.
const { mkdirSync, chmodSync } = await import('node:fs');
const baseDir = join(process.cwd(), '.openpencil-tmp');
mkdirSync(baseDir, { recursive: true });
chmodSync(baseDir, 0o700);
tempDir = await mkdtemp(join(baseDir, 'validate-'));
} else {
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'));
}
const tempPath = join(tempDir, 'screenshot.png');
try {
await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64'));
return await run(tempPath);
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Agent SDK: save screenshot to a temp PNG file inside the project directory,
* then ask Claude Code to read it (Claude Code's Read tool supports images
* natively). Must use insideProject=true because plan mode restricts reads
* to the project directory.
*/
async function validateViaAgentSDK(
body: ValidateBody,
requestedModel?: string,
): Promise<{ text: string; skipped?: boolean; error?: string }> {
return await withTempImageFile(
body.imageBase64,
async (tempPath) => {
const { query } = await import('@anthropic-ai/claude-agent-sdk');
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const claudePath = resolveClaudeCli();
const model = requestedModel;
const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design.
After viewing the image, analyze it according to these instructions:
${body.system}
${body.message}
CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.`;
const q = query({
prompt,
options: {
...(model ? { model } : {}),
maxTurns: 3,
tools: [],
plugins: [],
permissionMode: 'plan',
persistSession: false,
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess()
? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() }
: {}),
},
});
try {
for await (const message of q) {
if (message.type === 'result') {
const isErrorResult =
'is_error' in message && Boolean((message as { is_error?: boolean }).is_error);
if (message.subtype === 'success' && !isErrorResult) {
return { text: message.result };
}
const errors = 'errors' in message ? (message.errors as string[]) : [];
const resultText = 'result' in message ? String(message.result ?? '') : '';
return {
error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`,
text: '',
};
}
}
} finally {
q.close();
}
return { text: '', skipped: true };
},
true,
);
}
async function validateViaCodex(
body: ValidateBody,
model?: string,
): Promise<{ text: string; skipped?: boolean; error?: string }> {
return await withTempImageFile(body.imageBase64, async (tempPath) => {
const result = await runCodexExec(
`${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`,
{
model,
systemPrompt: body.system,
imageFiles: [tempPath],
},
);
if (result.error) {
return { text: '', error: result.error };
}
return { text: result.text ?? '' };
});
}
function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined {
if (!model || !model.includes('/')) return undefined;
const idx = model.indexOf('/');
return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) };
}
async function validateViaOpenCode(
body: ValidateBody,
model?: string,
): Promise<{ text: string; skipped?: boolean; error?: string }> {
let ocServer: { close(): void } | undefined;
try {
const { getOpencodeClient } = await import('../../utils/opencode-client');
const oc = await getOpencodeClient();
const ocClient: any = oc.client;
ocServer = oc.server;
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Validate',
});
if (sessionError || !session) {
return { text: '', error: 'Failed to create OpenCode session' };
}
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
});
const parsed = parseOpenCodeModel(model);
if (!parsed) {
return { text: '', error: 'Invalid OpenCode model format. Expected "provider/model".' };
}
const base64 = toImageBase64(body.imageBase64);
const promptPayload = {
sessionID: session.id,
model: parsed,
parts: [
{ type: 'image', url: `data:image/png;base64,${base64}` },
{
type: 'text',
text: `${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`,
},
],
};
const { data: result, error: promptError } = await ocClient.session.prompt(promptPayload);
if (promptError) {
return { text: '', error: 'OpenCode validation failed' };
}
const texts: string[] = [];
if (result?.parts) {
for (const part of result.parts) {
if (part.type === 'text' && part.text) {
texts.push(part.text);
}
}
}
return { text: texts.join('') };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { text: '', error: message };
} finally {
const { releaseOpencodeServer } = await import('../../utils/opencode-client');
releaseOpencodeServer(ocServer);
}
}
/** Validate via Gemini CLI — saves screenshot to temp file, asks CLI to read it */
async function validateViaGemini(
body: ValidateBody,
model?: string,
): Promise<{ text: string; skipped?: boolean; error?: string }> {
return await withTempImageFile(body.imageBase64, async (tempPath) => {
const { runGeminiExec } = await import('../../utils/gemini-client');
const prompt = `Read the image file at "${tempPath}". This is a PNG screenshot of a UI design.\n\n${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`;
const result = await runGeminiExec(prompt, {
model,
systemPrompt: body.system,
});
if (result.error) {
return { text: '', error: result.error };
}
return { text: result.text ?? '' };
});
}