openpencil/apps/web/server/api/ai/generate.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

463 lines
16 KiB
TypeScript

import { defineEventHandler, getHeader, readBody, setResponseHeaders } from 'h3';
import { resolveClaudeCli } from '../../utils/resolve-claude-cli';
import { runCodexExec, streamCodexExec } from '../../utils/codex-client';
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env';
import { formatOpenCodeError } from './chat';
import { createSSEResponse } from '../../utils/sse-stream';
interface GenerateBody {
system: string;
message: string;
model?: string;
provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini';
thinkingMode?: 'adaptive' | 'disabled' | 'enabled';
thinkingBudgetTokens?: number;
effort?: 'low' | 'medium' | 'high' | 'max';
}
/**
* Non-streaming AI generation endpoint.
* Routes to the appropriate provider SDK based on the `provider` field.
* Requires explicit provider and model; no fallback routing.
*/
export default defineEventHandler(async (event) => {
const body = await readBody<GenerateBody>(event);
if (!body?.message || body?.system == null) {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
return { error: 'Missing required fields: system, message' };
}
if (!body.provider) {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
return { error: 'Missing provider. Provider fallback is disabled.' };
}
if (!body.model?.trim()) {
setResponseHeaders(event, { 'Content-Type': 'application/json' });
return { error: 'Missing model. Model fallback is disabled.' };
}
const acceptSSE = getHeader(event, 'accept')?.includes('text/event-stream');
if (body.provider === 'anthropic') {
return acceptSSE ? streamViaAgentSDK(body, body.model) : generateViaAgentSDK(body, body.model);
}
if (body.provider === 'opencode') {
return acceptSSE ? streamViaOpenCode(body, body.model) : generateViaOpenCode(body, body.model);
}
if (body.provider === 'openai') {
return acceptSSE ? streamViaCodex(body, body.model) : generateViaCodex(body, body.model);
}
if (body.provider === 'gemini') {
return acceptSSE ? streamViaGemini(body, body.model) : generateViaGemini(body, body.model);
}
return { error: 'Missing or unsupported provider. Provider fallback is disabled.' };
});
/** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */
async function generateViaAgentSDK(
body: GenerateBody,
requestedModel?: string,
): Promise<{ text?: string; error?: string }> {
const runQuery = async (): Promise<{ text?: string; error?: string }> => {
const { query } = await import('@anthropic-ai/claude-agent-sdk');
// Remove CLAUDECODE env to allow running from within a CC terminal
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const model = requestedModel;
const claudePath = resolveClaudeCli();
const q = query({
prompt: body.message,
options: {
systemPrompt: body.system,
...(model ? { model } : {}),
maxTurns: 1,
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}`,
};
}
}
} finally {
q.close();
}
return { error: 'No result received from Claude Agent SDK' };
};
try {
return await runQuery();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: message };
}
}
async function generateViaCodex(
body: GenerateBody,
model?: string,
): Promise<{ text?: string; error?: string }> {
const result = await runCodexExec(body.message, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
});
return result.error ? { error: result.error } : { text: result.text ?? '' };
}
function mapOpenCodeEffort(
effort?: 'low' | 'medium' | 'high' | 'max',
): 'low' | 'medium' | 'high' | undefined {
if (!effort) return undefined;
if (effort === 'max') return 'high';
return effort;
}
function buildOpenCodeReasoning(body: GenerateBody): Record<string, unknown> | undefined {
const reasoning: Record<string, unknown> = {};
const effort = mapOpenCodeEffort(body.effort);
if (effort) {
reasoning.effort = effort;
}
if (body.thinkingMode === 'enabled') {
reasoning.enabled = true;
} else if (body.thinkingMode === 'disabled') {
reasoning.enabled = false;
}
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
reasoning.budgetTokens = body.thinkingBudgetTokens;
}
return Object.keys(reasoning).length > 0 ? reasoning : undefined;
}
/** Timeout for OpenCode prompt calls (3 minutes) */
const OPENCODE_PROMPT_TIMEOUT_MS = 180_000;
async function promptWithTimeout(
ocClient: any,
payload: Record<string, unknown>,
timeoutMs = OPENCODE_PROMPT_TIMEOUT_MS,
): Promise<{ data: any; error: any }> {
const result = await Promise.race([
ocClient.session.prompt(payload),
new Promise<{ data: null; error: string }>((resolve) =>
setTimeout(
() =>
resolve({ data: null, error: `OpenCode prompt timed out after ${timeoutMs / 1000}s` }),
timeoutMs,
),
),
]);
return result;
}
async function promptOpenCodeWithThinking(
ocClient: any,
basePayload: Record<string, unknown>,
body: GenerateBody,
): Promise<{ data: any; error: any }> {
const reasoning = buildOpenCodeReasoning(body);
if (!reasoning) {
return await promptWithTimeout(ocClient, basePayload);
}
const enhanced = { ...basePayload, reasoning };
const firstTry = await promptWithTimeout(ocClient, enhanced);
if (!firstTry.error) {
return firstTry;
}
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.');
return await promptWithTimeout(ocClient, basePayload);
}
/** Generate via OpenCode SDK (connects to a running OpenCode server) */
async function generateViaOpenCode(
body: GenerateBody,
model?: string,
): Promise<{ text?: string; error?: string }> {
let ocServer: { close(): void } | undefined;
try {
const { getOpencodeClient } = await import('../../utils/opencode-client');
const oc = await getOpencodeClient();
const ocClient = oc.client;
ocServer = oc.server;
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Generate',
});
if (sessionError || !session) {
const detail = formatOpenCodeError(sessionError);
return { error: `Failed to create OpenCode session: ${detail}` };
}
// Inject system prompt as context (no AI reply)
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
});
// Parse model string ("providerID/modelID")
let modelOption: { providerID: string; modelID: string } | undefined;
if (model && model.includes('/')) {
const idx = model.indexOf('/');
modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) };
} else if (model) {
console.warn(
`[AI] OpenCode generate: could not parse model string "${model}", sending without model override`,
);
}
// Send main prompt and await full response
const promptPayload: Record<string, unknown> = {
sessionID: session.id,
...(modelOption ? { model: modelOption } : {}),
parts: [{ type: 'text', text: body.message }],
};
console.log(`[AI] OpenCode generate: model=${model}, parsed=${JSON.stringify(modelOption)}`);
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
ocClient,
promptPayload,
body,
);
if (promptError) {
const errorDetail = formatOpenCodeError(promptError);
console.error('[AI] OpenCode generate error:', errorDetail);
return { error: errorDetail };
}
// Extract text from response parts
const texts: string[] = [];
if (result?.parts) {
for (const part of result.parts) {
if (part.type === 'text' && part.text) {
texts.push(part.text);
}
}
}
if (texts.length === 0) {
return {
error: 'OpenCode returned an empty response. The model may not have generated any output.',
};
}
return { text: texts.join('') };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { error: message };
} finally {
const { releaseOpencodeServer } = await import('../../utils/opencode-client');
releaseOpencodeServer(ocServer);
}
}
/** Generate via Gemini CLI (`gemini -p -o json`) — CLI handles its own auth */
async function generateViaGemini(
body: GenerateBody,
model?: string,
): Promise<{ text?: string; error?: string }> {
const { runGeminiExec } = await import('../../utils/gemini-client');
return runGeminiExec(body.message, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
});
}
// ─── SSE streaming variants ───────────────────────────────────────────────────
/** Stream via Claude Agent SDK — emits text/thinking deltas as SSE */
function streamViaAgentSDK(body: GenerateBody, requestedModel?: string) {
return createSSEResponse(async (emit) => {
const { query } = await import('@anthropic-ai/claude-agent-sdk');
const env = buildClaudeAgentEnv();
const debugFile = getClaudeAgentDebugFilePath();
const model = requestedModel;
const claudePath = resolveClaudeCli();
const q = query({
prompt: body.message,
options: {
systemPrompt: body.system,
...(model ? { model } : {}),
maxTurns: 1,
includePartialMessages: true,
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 === 'stream_event') {
const ev = (message as any).event;
if (ev.type === 'content_block_delta') {
if (ev.delta.type === 'text_delta') emit({ type: 'text', content: ev.delta.text });
else if (ev.delta.type === 'thinking_delta')
emit({ type: 'thinking', content: (ev.delta as any).thinking });
}
} else if (message.type === 'result') {
const isError = 'is_error' in message && Boolean((message as any).is_error);
if (message.subtype !== 'success' || isError) {
const errors = 'errors' in message ? (message.errors as string[]) : [];
const resultText = 'result' in message ? String((message as any).result ?? '') : '';
throw new Error(errors.join('; ') || resultText || `Query ended: ${message.subtype}`);
}
}
}
} finally {
q.close();
}
});
}
/** Stream via Codex CLI — emits text/error deltas as SSE */
function streamViaCodex(body: GenerateBody, model?: string) {
return createSSEResponse(async (emit) => {
let hasOutput = false;
for await (const event of streamCodexExec(body.message, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
})) {
emit(event);
if (event.type === 'text') hasOutput = true;
}
if (!hasOutput) throw new Error('Codex returned no output.');
});
}
/** Parse an OpenCode model string ("providerID/modelID") into its parts */
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) };
}
/** Stream via OpenCode SDK — emits text/thinking deltas as SSE */
function streamViaOpenCode(body: GenerateBody, model?: string) {
return createSSEResponse(async (emit) => {
const { getOpencodeClient, releaseOpencodeServer } =
await import('../../utils/opencode-client');
const oc = await getOpencodeClient();
try {
const ocClient = oc.client;
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Generate',
});
if (sessionError || !session)
throw new Error(`Session create failed: ${formatOpenCodeError(sessionError)}`);
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
});
const parsed = parseOpenCodeModel(model);
const basePayload: Record<string, unknown> = {
sessionID: session.id,
...(parsed ? { model: parsed } : {}),
parts: [{ type: 'text', text: body.message }],
};
const reasoning = buildOpenCodeReasoning(body);
const payloadWithReasoning = reasoning ? { ...basePayload, reasoning } : basePayload;
const eventResult = await ocClient.event.subscribe();
await new Promise<void>((r) => setTimeout(r, 100));
let { error: asyncError } = await ocClient.session.promptAsync(payloadWithReasoning as any);
if (asyncError && reasoning) {
console.warn('[AI] OpenCode reasoning rejected, retrying without');
({ error: asyncError } = await ocClient.session.promptAsync(basePayload as any));
}
if (asyncError) throw new Error(formatOpenCodeError(asyncError));
const sessionId = session.id;
for await (const event of eventResult.stream) {
const eventType = (event as any).type as string;
const props = (event as any).properties;
if (eventType === 'message.part.delta' && props?.sessionID === sessionId) {
if (props.field === 'text') emit({ type: 'text', content: props.delta });
if (props.field === 'reasoning') emit({ type: 'thinking', content: props.delta });
}
if (eventType === 'session.idle' && props?.sessionID === sessionId) break;
if (
eventType === 'session.error' &&
(props?.sessionID === sessionId || !props?.sessionID)
) {
throw new Error(formatOpenCodeError(props?.error));
}
}
} finally {
releaseOpencodeServer(oc.server);
}
});
}
/** Stream via Gemini CLI — emits text deltas as SSE */
function streamViaGemini(body: GenerateBody, model?: string) {
return createSSEResponse(async (emit) => {
const { streamGeminiExec } = await import('../../utils/gemini-client');
const { stream } = streamGeminiExec(body.message, {
model,
systemPrompt: body.system,
thinkingMode: body.thinkingMode,
thinkingBudgetTokens: body.thinkingBudgetTokens,
effort: body.effort,
});
for await (const event of stream) {
if (event.type === 'text') emit({ type: 'text', content: event.content });
if (event.type === 'error') throw new Error(event.content);
}
});
}