← 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 };