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).
463 lines
16 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|