LLM: streaming + rich context + 32×32 frames + proxy log

- Streaming: requestSongTweak now uses streamObject when a progress
  callback is provided (via handleLlmTweak), emitting partial {rationale,
  edits} and updating toast/console as text arrives; falls back to
  generateObject if gateway doesn't support streaming. Models always
  called via provider.chat(modelId) / /v1/chat/completions, the correct
  path for meta/muse-spark-1.3-contributor via gateway.

- Rich context: spec.js gains FULL control wording (any amount/arrangement/
  bpm allowed), and detailed payload — trackSummary with bandBalance,
  tempo, normalization (per-feature lo/hi), sectionsDetailed (energy/
  flux/centroid/flatness/width/slope/endEnergy/duration), stats
  (kinds, avg/max energy, bars). LLM told You have FULL control.

- Low-res vision: new src/llm/thumbs.js — captureThumbnails(show, {8,32})
  samples section midpoints, warms up state, renders via Show, box-averages
  down to 32×32 JPEG data URLs, restores timeline. main.js now captures 8
  frames with two animation frames of pause, then passes them as {type:
  image, image: dataUrl} parts in the user message (llmTweak.js
  attachFramesToMessages).

- Proxy logging (no bearer): vite.config.js llmGatewayProxy now logs
  sanitized requests/responses to logs/llm-proxy.log (JSON lines, 1MB
  rotation, redacts apiKey/authorization from path/body/headers) so the
  coding agent can inspect prompts without re-calling. .gitignore adds
  logs/.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-09-13 21:54:07 +02:00
co-authored by Claude
parent e2f56e3ce4
commit e0e619af85
6 changed files with 548 additions and 49 deletions
+127 -14
View File
@@ -1,6 +1,6 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'fs';
/**
* Let the measuring pages write their results back into the source tree.
@@ -63,8 +63,77 @@ function measurementWriter() {
* Production builds are static; there this route does not exist and the
* client falls through to a direct fetch. Deployments that need CORS-free
* production should add an equivalent `/api/llm` serverless proxy.
*
* Also logs sanitized traffic (no Authorization) to `logs/llm-proxy.log` so
* the coding agent can inspect prompts/responses without re-calling the LLM.
*/
function llmGatewayProxy() {
const LOG_DIR = resolve(process.cwd(), 'logs');
const LOG_FILE = resolve(LOG_DIR, 'llm-proxy.log');
function safeAppend(entry) {
try {
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
// Rotate: truncate if >1MB to avoid unbounded growth
try {
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > 1024 * 1024) {
writeFileSync(LOG_FILE, '');
}
} catch { /* ignore stat/truncate errors */ }
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
} catch { /* ignore */ }
}
function sanitizePathForLog(path) {
try {
const u = new URL(path, 'http://localhost');
for (const key of [...u.searchParams.keys()]) {
const lower = key.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
u.searchParams.delete(key);
}
}
return u.pathname + u.search;
} catch {
// Fallback: strip sensitive query params via regex
return path.replace(/([?&])(apiKey|api_key|authorization)=[^&]*/gi, '').replace(/[?&]$/, '').replace(/\?&/, '?') || '/';
}
}
function redactJsonForLog(obj) {
if (Array.isArray(obj)) return obj.map(redactJsonForLog);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const lower = k.toLowerCase();
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
out[k] = '[REDACTED]';
} else {
out[k] = redactJsonForLog(v);
}
}
return out;
}
return obj;
}
function sanitizeBodyForLog(ct, raw) {
// Don't log if content-type is missing or body is binary
if (!ct || !ct.includes('json')) {
if (raw && raw.length > 4096) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`;
try { return raw ? raw.toString('utf8').slice(0, 4096) : ''; } catch { return '[unprintable]'; }
}
try {
const text = raw ? raw.toString('utf8') : '';
const parsed = JSON.parse(text);
// Redact sensitive keys if they somehow appear in JSON body
const redacted = redactJsonForLog(parsed);
return JSON.stringify(redacted).slice(0, 16384);
} catch {
return raw ? raw.toString('utf8').slice(0, 4096) : '';
}
}
return {
name: 'flow-state-llm-gateway-proxy',
apply: 'serve',
@@ -85,16 +154,7 @@ function llmGatewayProxy() {
}
const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh';
// Strip the proxy prefix; the client sends `/__llm/v1/...`,
// so after stripping we have `/v1/...` which joins correctly.
const pathAfter = req.url.replace(/^\/__llm/, '') || '/';
// Preserve query string
const targetUrl = new URL(pathAfter, gatewayBase.endsWith('/') ? gatewayBase : gatewayBase + '/');
// But `new URL('/v1/x', 'https://host/v1')` drops the base path —
// `/v1/x` is absolute. Since `pathAfter` already starts with `/v1`,
// joining against the origin is correct; gatewayBase's path is just
// the fallback origin. So rebuild against origin only.
// Simpler: construct from origin + pathAfter
let origin;
try {
origin = new URL(gatewayBase).origin;
@@ -106,14 +166,28 @@ function llmGatewayProxy() {
let bodyChunks = [];
req.on('data', (c) => bodyChunks.push(c));
req.on('end', async () => {
const started = Date.now();
const reqCt = req.headers['content-type'] || '';
const rawReq = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
const reqLog = sanitizeBodyForLog(reqCt, rawReq);
// Always log the request (sanitized — no Authorization/apiKey)
safeAppend({
ts: new Date().toISOString(),
dir: '→',
method: req.method,
path: sanitizePathForLog(pathAfter),
gateway: origin,
contentType: reqCt,
// Authorization header and apiKey query param never logged
body: reqLog,
});
try {
const body = bodyChunks.length ? Buffer.concat(bodyChunks) : undefined;
const body = rawReq && rawReq.length ? rawReq : undefined;
const headers = {};
// Forward relevant headers
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'];
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'];
else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`;
// Also forward any x- header the gateway might care about
for (const [k, v] of Object.entries(req.headers)) {
if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v;
}
@@ -124,7 +198,6 @@ function llmGatewayProxy() {
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
});
// Mirror status and content headers
const contentType = upstream.headers.get('content-type');
res.writeHead(upstream.status, {
...(contentType ? { 'content-type': contentType } : {}),
@@ -133,7 +206,47 @@ function llmGatewayProxy() {
const buf = Buffer.from(await upstream.arrayBuffer());
res.end(buf);
// Log response (sanitized — still no token in body)
const elapsed = Date.now() - started;
let respLog = '';
try {
const ct = contentType || '';
if (ct.includes('json') || ct.includes('event-stream') || buf.length < 32768) {
const text = buf.toString('utf8');
// For SSE streams, keep first 16k; for JSON, keep parsed form
if (ct.includes('json')) {
try { respLog = JSON.stringify(JSON.parse(text)).slice(0, 16384); } catch { respLog = text.slice(0, 8192); }
} else {
respLog = text.slice(0, 8192);
}
} else {
respLog = `[${contentType} ${buf.length} bytes]`;
}
} catch { respLog = `[${buf.length} bytes]`; }
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: upstream.status,
elapsedMs: elapsed,
contentType: contentType || '',
body: respLog,
});
// Also emit to vite's console for live tailing
const tag = upstream.status >= 400 ? '← ERR' : '← OK';
console.info(`[llm-proxy] ${tag} ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${buf.length} bytes)`);
} catch (err) {
const elapsed = Date.now() - started;
safeAppend({
ts: new Date().toISOString(),
dir: '←',
status: 502,
elapsedMs: elapsed,
error: String(err && err.message ? err.message : err).slice(0, 2000),
});
console.error(`[llm-proxy] ← 502 ${req.method} ${pathAfter}`, err);
res.writeHead(502, { 'access-control-allow-origin': '*' });
res.end(String(err && err.message ? err.message : err));
}