Move LLM key to the server, add random-style buttons
Architectural change
The browser no longer talks to the LLM directly. A new Node server
(server.mjs) sits in the middle:
[Browser] → [Node :3000] → [LLM provider]
(no key) (key in .env)
server.mjs is the production runtime: it serves the built SPA out of
dist/ and exposes three JSON endpoints that proxy to the LLM with
credentials held in process.env. The browser-side llm.ts is now a thin
fetch wrapper.
- server.mjs: single-file Node server, no production deps
- server/prompts.mjs: system + user prompt construction (was client-side)
- src/lib/prompts.ts removed (moved server-side)
- src/lib/llm.ts rewritten — no more direct LLM calls, no more
JSON extraction, no more validation; just fetch the proxy
- src/lib/types.ts: drop SaveConfigPayload/TestConnectionResult, add
style_hint and ServerStatus
- vite.config.ts: proxy /api/* → localhost:3000 in dev
- .env.example: LLM_* and PORT/CORS_ORIGIN instead of Supabase values
New feature: random style buttons
The Options → Music style field now has two AI buttons that fill it
with a fresh Suno style description:
- 'Surprise me' → coherent, production-ready style (max 25 words)
- 'Go crazy' → deliberately clashing genre mashup (max 25 words)
The buttons hit a dedicated /api/style/random endpoint on the server
that uses a small, focused system prompt. Each click overwrites the
field. Both buttons show a spinner and disable while a request is
in flight. Errors surface as toasts. AbortController is used so a
fast second click cancels the first.
When the Music style field is non-empty at generation time, its value
is sent to the model as style_hint and used as the basis for the full
120-word style field (per the updated system prompt).
Other UX
- Settings page is now a server-status page: green/red indicator,
model + endpoint, re-check button. The API key is no longer
configurable in the browser (it never was reachable anyway — now
the UI is honest about that).
- Home page header shows a small 'Server offline' warning when the
server is unreachable.
- Settings has a Local data section: list what's in localStorage
with one-click clear-history and clear-all buttons (with confirm).
- Esc cancels any in-flight generation.
- ZIP filename falls back to 'song' if the title sanitizes to empty.
Deployment
deploy/ holds reference files (Dockerfile, docker-compose example,
Caddy fragment, generate-env.sh, README) for adding the service to a
Jannik-Cloud-style stack. The repo is intentionally not wired into
the Jannik-Cloud repo; copy the four files when ready.
This commit is contained in:
+444
@@ -0,0 +1,444 @@
|
||||
// MelodyMuse — single-file production server.
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1. Serve the built Vite SPA from `./dist/` (SPA fallback to index.html).
|
||||
// 2. Proxy LLM calls through three JSON endpoints so the browser never
|
||||
// sees the API key.
|
||||
//
|
||||
// Run with: node server.mjs
|
||||
// Or via: npm start
|
||||
//
|
||||
// Required env vars (the server exits if any are missing):
|
||||
// LLM_ENDPOINT — OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1
|
||||
// LLM_API_KEY — provider secret key
|
||||
// Optional env vars:
|
||||
// LLM_MODEL — model name (default: MiniMax-M3)
|
||||
// PORT — listen port (default: 3000)
|
||||
// CORS_ORIGIN — "*" to allow any origin, or a specific origin
|
||||
// (default: "*", fine for single-user personal use)
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
buildStylePrompt,
|
||||
buildSystemPrompt,
|
||||
buildUserMessage,
|
||||
} from './server/prompts.mjs';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Configuration
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const DIST = join(__dirname, 'dist');
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
|
||||
|
||||
const LLM = {
|
||||
endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''),
|
||||
apiKey: process.env.LLM_API_KEY || '',
|
||||
model: process.env.LLM_MODEL || 'MiniMax-M3',
|
||||
};
|
||||
|
||||
if (!LLM.endpoint || !LLM.apiKey) {
|
||||
console.error('');
|
||||
console.error(' [FATAL] LLM_ENDPOINT and LLM_API_KEY must be set.');
|
||||
console.error(` LLM_ENDPOINT: ${LLM.endpoint || '<missing>'}`);
|
||||
console.error(` LLM_API_KEY: ${LLM.apiKey ? '********' : '<missing>'}`);
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
|
||||
console.log(`[startup] LLM model: ${LLM.model}`);
|
||||
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// JSON extraction + shape validation
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isObject(v) {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
// Pull a JSON object out of a model reply that may have wrapped it in
|
||||
// ```json ... ``` fences or added a brief preamble.
|
||||
function extractJson(text) {
|
||||
const trimmed = text.trim();
|
||||
try { return JSON.parse(trimmed); } catch { /* fall through */ }
|
||||
|
||||
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fenced) {
|
||||
try { return JSON.parse(fenced[1].trim()); } catch { /* fall through */ }
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf('{');
|
||||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
||||
const slice = trimmed.slice(firstBrace, lastBrace + 1);
|
||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
||||
}
|
||||
|
||||
throw new Error('Model response did not contain a parseable JSON object');
|
||||
}
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
'titles',
|
||||
'lyrics',
|
||||
'style',
|
||||
'negative_style',
|
||||
'youtube_description',
|
||||
'video_prompts',
|
||||
];
|
||||
|
||||
function validateShape(parsed) {
|
||||
if (!isObject(parsed)) throw new Error('Model response is not an object');
|
||||
for (const k of REQUIRED_KEYS) {
|
||||
if (!(k in parsed)) continue; // partial regen may omit keys
|
||||
const v = parsed[k];
|
||||
if (k === 'titles') {
|
||||
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
|
||||
throw new Error('"titles" must be an array of strings');
|
||||
}
|
||||
} else if (k === 'video_prompts') {
|
||||
if (!Array.isArray(v)) throw new Error('"video_prompts" must be an array');
|
||||
} else if (typeof v !== 'string') {
|
||||
throw new Error(`"${k}" must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateFullAssets(parsed) {
|
||||
if (!isObject(parsed)) throw new Error('Model response is not an object');
|
||||
for (const k of REQUIRED_KEYS) {
|
||||
if (!(k in parsed)) {
|
||||
throw new Error(`Model response is missing required key "${k}"`);
|
||||
}
|
||||
}
|
||||
validateShape(parsed);
|
||||
}
|
||||
|
||||
// For partial regen, drop any keys the model returned that we didn't ask for.
|
||||
// Keeps the merged state predictable.
|
||||
function pickResultForSection(section, result) {
|
||||
switch (section) {
|
||||
case 'titles':
|
||||
return { titles: result.titles };
|
||||
case 'lyrics':
|
||||
return { lyrics: result.lyrics };
|
||||
case 'style':
|
||||
return {
|
||||
style: result.style,
|
||||
negative_style: result.negative_style,
|
||||
};
|
||||
case 'video_prompts':
|
||||
return { video_prompts: result.video_prompts };
|
||||
case 'youtube_description':
|
||||
return { youtube_description: result.youtube_description };
|
||||
default:
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Provider call
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function callProvider(messages, opts = {}, signal) {
|
||||
const url = `${LLM.endpoint}/chat/completions`;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${LLM.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: LLM.model,
|
||||
max_tokens: opts.max_tokens ?? 4000,
|
||||
messages,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
throw new Error(
|
||||
`Could not reach ${url}. Check LLM_ENDPOINT and that the provider is reachable from this server. (Details: ${err.message})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
throw new Error('Provider response did not include a message');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Strip a leading "Style: " or surrounding quotes that some models like to add.
|
||||
function cleanStyleString(s) {
|
||||
let out = s.trim();
|
||||
// Strip a single pair of wrapping quotes, either kind.
|
||||
if (
|
||||
(out.startsWith('"') && out.endsWith('"')) ||
|
||||
(out.startsWith("'") && out.endsWith("'"))
|
||||
) {
|
||||
out = out.slice(1, -1).trim();
|
||||
}
|
||||
// Strip a "Style:" / "Style prompt:" prefix if the model added one.
|
||||
out = out.replace(/^(style\s*(prompt)?\s*[:\-]\s*)/i, '');
|
||||
// Strip a trailing period.
|
||||
out = out.replace(/\.+$/, '').trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATIC_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2':'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
};
|
||||
|
||||
function json(res, body, status = 200) {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
// Guard against runaway payloads.
|
||||
if (data.length > 1_000_000) {
|
||||
reject(new Error('Request body too large'));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data) return resolve({});
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { reject(new Error('Invalid JSON body')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Combined abort signal: the request stream closing (client disconnect) OR
|
||||
// the explicit AbortSignal passed in.
|
||||
function combinedSignal(req, explicit) {
|
||||
const ctrl = new AbortController();
|
||||
const onClose = () => ctrl.abort(new Error('client disconnected'));
|
||||
req.once('close', onClose);
|
||||
if (explicit) {
|
||||
if (explicit.aborted) ctrl.abort(explicit.reason);
|
||||
else explicit.addEventListener('abort', () => ctrl.abort(explicit.reason), { once: true });
|
||||
}
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
async function serveStatic(req, res) {
|
||||
// Strip query string and decode.
|
||||
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
// Disallow path traversal.
|
||||
if (urlPath.includes('..')) {
|
||||
res.writeHead(400); res.end('Bad request'); return;
|
||||
}
|
||||
|
||||
let filePath = join(DIST, urlPath);
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
if (s.isDirectory()) filePath = join(filePath, 'index.html');
|
||||
} catch {
|
||||
// SPA fallback — serve index.html for unknown routes.
|
||||
filePath = join(DIST, 'index.html');
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
const type = STATIC_TYPES[ext] || 'application/octet-stream';
|
||||
// Hashed Vite assets get long-lived caching; index.html and the un-hashed
|
||||
// root never get cached so deploys take effect immediately.
|
||||
const isHashedAsset = /\/assets\//.test(urlPath) && /\.[a-z0-9]{8}\./.test(urlPath);
|
||||
const cacheControl = isHashedAsset
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache';
|
||||
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl });
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
res.writeHead(500); res.end('Internal error');
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Route handlers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyCors(res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
}
|
||||
|
||||
async function handleHealth(req, res) {
|
||||
json(res, {
|
||||
ok: true,
|
||||
llm_configured: true,
|
||||
model: LLM.model,
|
||||
endpoint: LLM.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStyleRandom(req, res) {
|
||||
let body;
|
||||
try { body = await readJsonBody(req); }
|
||||
catch (err) { return json(res, { error: err.message }, 400); }
|
||||
|
||||
const mode = body?.mode === 'crazy' ? 'crazy' : 'normal';
|
||||
const signal = combinedSignal(req);
|
||||
|
||||
try {
|
||||
const content = await callProvider(
|
||||
[
|
||||
{ role: 'system', content: buildStylePrompt(mode) },
|
||||
{ role: 'user', content: 'Generate one style description now.' },
|
||||
],
|
||||
{ max_tokens: 120 },
|
||||
signal,
|
||||
);
|
||||
const style = cleanStyleString(content);
|
||||
if (!style) throw new Error('Model returned an empty style');
|
||||
json(res, { style, mode });
|
||||
} catch (err) {
|
||||
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
|
||||
return; // Client gave up; no point responding.
|
||||
}
|
||||
console.error('[style/random]', err);
|
||||
json(res, { error: err.message || 'Failed to generate style' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(req, res) {
|
||||
let body;
|
||||
try { body = await readJsonBody(req); }
|
||||
catch (err) { return json(res, { error: err.message }, 400); }
|
||||
|
||||
if (!body || typeof body.input !== 'string' || !body.input.trim()) {
|
||||
return json(res, { error: 'Missing "input"' }, 400);
|
||||
}
|
||||
if (!body.section) body.section = 'all';
|
||||
if (!body.vocals) body.vocals = 'vocals';
|
||||
if (!body.language) body.language = 'English';
|
||||
|
||||
const signal = combinedSignal(req);
|
||||
|
||||
try {
|
||||
const content = await callProvider(
|
||||
[
|
||||
{ role: 'system', content: buildSystemPrompt(body) },
|
||||
{ role: 'user', content: buildUserMessage(body) },
|
||||
],
|
||||
{ max_tokens: 4000 },
|
||||
signal,
|
||||
);
|
||||
|
||||
const parsed = extractJson(content);
|
||||
|
||||
if (body.section === 'all') {
|
||||
validateFullAssets(parsed);
|
||||
} else {
|
||||
validateShape(parsed);
|
||||
}
|
||||
|
||||
const result = body.section === 'all' ? parsed : pickResultForSection(body.section, parsed);
|
||||
json(res, result);
|
||||
} catch (err) {
|
||||
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
|
||||
return;
|
||||
}
|
||||
console.error('[generate]', err);
|
||||
json(res, { error: err.message || 'Generation failed' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Server
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
applyCors(res);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// API
|
||||
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
||||
return handleHealth(req, res);
|
||||
}
|
||||
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
|
||||
return handleGenerate(req, res);
|
||||
}
|
||||
if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') {
|
||||
return handleStyleRandom(req, res);
|
||||
}
|
||||
|
||||
// Anything under /api that didn't match is a 404 (don't fall through to the SPA).
|
||||
if (req.url.startsWith('/api/')) {
|
||||
return json(res, { error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return serveStatic(req, res);
|
||||
}
|
||||
|
||||
res.writeHead(405);
|
||||
res.end('Method not allowed');
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
// The boot log already happened above.
|
||||
});
|
||||
|
||||
// Graceful shutdown so the process exits cleanly when Docker stops it.
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(sig, () => {
|
||||
console.log(`[shutdown] ${sig} received, closing server…`);
|
||||
server.close(() => process.exit(0));
|
||||
// Hard exit if it takes too long.
|
||||
setTimeout(() => process.exit(1), 5000).unref();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user