LLM song-tweak: AI Gateway via Vercel AI SDK, budgeted tune flow

Add a ✨ tune button that lets an LLM inspect the proposed
visualization and tweak the proc-song knobs so the next generation
looks better. Edits only the 7 songbank params (bpm, brightness,
noise, noiseColour, dynamics, transition, arrangement) — never the
Look — then re-synthesizes and re-generates locally for preview.

- ai + @ai-sdk/openai + zod (library per plan) against the Vercel AI
  Gateway (OpenAI-compatible, configurable via
  VITE_AI_GATEWAY_URL/MODEL/API_KEY, localStorage override,
  .env.example).
- Structured output (TweakResultSchema) + clampEdits enforces ranges;
  prompt ≤ ~2.5k tokens, max 2-3 knobs ±0.15 / bpm ±8.
- Budget caps: 3/load + 10/session (sessionStorage), checked before
  network, NO_EDITS refunded, offline/auth/rate-limit mapped.
- Prompt split into src/llm/spec.js (pure, node-testable) + llmTweak.js
  (gateway + digest) to keep `node --test` off the Vite JSON path.
- UI: ⚙️ gateway/model/key dialog (localStorage flow-state:llm),
  confirm(prompt) with hint, diff modal with Apply/Dismiss; Apply
  synthesizes → FeatureTrack → generateLook → Show.useTrack.
- Tests: 7 new LLM cases (clamp, schema, prompt, budget), 18/18 pass.
  Build + lint:scenes green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-09-13 20:03:27 +02:00
co-authored by Claude
parent 7736390f9c
commit b7c3537fcb
12 changed files with 980 additions and 2 deletions
+87
View File
@@ -0,0 +1,87 @@
// LLM gateway config — localStorage overrides > VITE env > defaults.
//
// The app is a static Vite site with no server; the gateway credential lives
// in the browser. Never commit a key — paste it in the panel (→ localStorage)
// or in an untracked .env.local.
const STORAGE_KEY = 'flow-state:llm';
const DEFAULTS = {
gatewayUrl: 'https://ai-gateway.vercel.sh/v1',
model: 'openai/gpt-4o-mini',
apiKey: '',
};
function readEnv() {
try {
const env = import.meta.env || {};
return {
gatewayUrl: env.VITE_AI_GATEWAY_URL || null,
model: env.VITE_AI_MODEL || null,
apiKey: env.VITE_AI_API_KEY || null,
};
} catch {
return { gatewayUrl: null, model: null, apiKey: null };
}
}
function readStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
/** Resolved config — localStorage wins, then env, then defaults. */
export function getLlmConfig() {
const env = readEnv();
const stored = readStorage();
const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl;
const model = stored.model || env.model || DEFAULTS.model;
const apiKey = stored.apiKey != null ? stored.apiKey : (env.apiKey != null ? env.apiKey : DEFAULTS.apiKey);
const key = typeof apiKey === 'string' ? apiKey.trim() : '';
return {
gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl,
model: String(model || DEFAULTS.model).trim() || DEFAULTS.model,
apiKey: key,
isConfigured: key.length > 0,
defaults: { ...DEFAULTS },
env: { ...env },
stored: { ...stored },
};
}
/** Merge patch into localStorage. Pass null/'' to clear a field. */
export function setLlmConfig(patch) {
const current = readStorage();
const next = { ...current };
for (const k of ['gatewayUrl', 'model', 'apiKey']) {
if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
const v = patch[k];
if (v == null || v === '') {
delete next[k];
} else {
next[k] = String(v).trim();
}
}
}
try {
if (!Object.keys(next).length) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// storage full / blocked — ignore, caller sees isConfigured false
}
return getLlmConfig();
}
export function clearLlmConfig() {
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
return getLlmConfig();
}
export const LLM_STORAGE_KEY = STORAGE_KEY;
export const LLM_DEFAULTS = DEFAULTS;