← back to Visual Factory
src/llm.js
136 lines
// Visual Factory — LLM adapters.
// All-local, all-free. No Anthropic API.
// ollama() → qwen3:14b for spec/HTML/iterate; llava for vision
// claudeCli() → critic only (one-call-per-run, Max sub)
const { spawn } = require('node:child_process');
const fs = require('node:fs/promises');
// P1 fix 2026-05-04: default to MS1 per Steve's standing rule
// `feedback_ollama_default_ms1.md` — Mac2 froze 2026-05-02 under GPU contention.
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://192.168.1.133:11434';
const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
async function ollama({ model, system, prompt, format = null, temperature = 0.2, images = null, signal = null }) {
const message = { role: 'user', content: prompt };
if (images && images.length) {
message.images = await Promise.all(images.map(async p => {
const buf = await fs.readFile(p);
return buf.toString('base64');
}));
}
// num_predict caps llava's output to prevent the degenerate "1000 empty strings"
// mode that breaks JSON parsing. 1500 tokens is plenty for the small JSON we want.
const body = {
model,
stream: false,
options: { temperature, num_predict: 1500 },
messages: [
...(system ? [{ role: 'system', content: system }] : []),
message
]
};
if (format === 'json') body.format = 'json';
// Retry transient fetch failures (socket drops under concurrent load).
// Backoff: 2s, 5s, 12s.
const delays = [2000, 5000, 12000];
let lastErr;
for (let attempt = 0; attempt <= delays.length; attempt++) {
if (signal?.aborted) throw new Error('ollama call aborted (stage timeout or cancellation)');
try {
const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal || undefined
});
if (!res.ok) {
const txt = await res.text();
// Treat 5xx as transient; 4xx as permanent
if (res.status >= 500 && attempt < delays.length) {
lastErr = new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
} else {
throw new Error(`ollama ${model} http ${res.status}: ${txt.slice(0, 200)}`);
}
} else {
const data = await res.json();
const content = data?.message?.content ?? '';
if (format === 'json') {
try { return JSON.parse(content); }
catch (e) { throw new Error(`ollama ${model} did not return valid JSON: ${content.slice(0, 200)}`); }
}
return content;
}
} catch (err) {
lastErr = err;
// AbortError = caller cancelled (stage timeout). Don't retry.
if (err?.name === 'AbortError' || signal?.aborted) {
throw new Error('ollama call aborted (stage timeout or cancellation)');
}
// TypeError: fetch failed = socket-level. Retry.
if (!(err instanceof TypeError) && !err.message?.includes('ollama') ) throw err;
if (attempt === delays.length) break;
}
// Honor abort during back-off too — otherwise a cancelled stage waits the full 19s.
// If the signal aborted just before this Promise body runs, addEventListener
// attaches to an already-aborted signal and never fires. Check first.
await new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(new Error('ollama call aborted (during retry back-off)'));
}
const t = setTimeout(resolve, delays[attempt]);
if (signal) {
const onAbort = () => { clearTimeout(t); reject(new Error('ollama call aborted (during retry back-off)')); };
signal.addEventListener('abort', onAbort, { once: true });
}
});
}
throw lastErr || new Error(`ollama ${model} failed after retries`);
}
function claudeCli({ prompt, timeoutMs = 180000, signal = null }) {
// Pipe prompt via stdin instead of the `-p` CLI flag — avoids arg-length / quoting
// issues when the prompt contains JSON, newlines, or non-ASCII chars.
// signal: external AbortSignal (e.g., from stage timeout) — kills the child.
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error('claude CLI aborted before spawn'));
const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let settled = false;
const cleanup = () => {
clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
};
const finish = (fn, val) => { if (settled) return; settled = true; cleanup(); fn(val); };
const timer = setTimeout(() => {
try { child.kill('SIGTERM'); } catch {}
finish(reject, new Error(`claude CLI timed out after ${timeoutMs}ms`));
}, timeoutMs);
let onAbort = null;
if (signal) {
onAbort = () => {
try { child.kill('SIGTERM'); } catch {}
// SIGKILL fallback if it doesn't exit cleanly within 2s.
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 2000).unref?.();
finish(reject, new Error('claude CLI aborted (stage timeout or cancellation)'));
};
signal.addEventListener('abort', onAbort, { once: true });
}
child.stdout.on('data', d => { stdout += d; });
child.stderr.on('data', d => { stderr += d; });
child.on('error', err => finish(reject, new Error(`claude CLI spawn error: ${err.message}`)));
child.on('close', code => {
if (code !== 0) return finish(reject, new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
finish(resolve, stdout.trim());
});
try { child.stdin.end(prompt); } catch (e) { finish(reject, new Error(`claude CLI stdin write failed: ${e.message}`)); }
});
}
module.exports = { ollama, claudeCli };