Files

654 lines
23 KiB
JavaScript

// 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, writeFile, stat, mkdir } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildStylePrompt,
buildSystemPrompt,
buildUserMessage,
setActivePrompts,
DEFAULT_PROMPTS,
} 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 DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
const HISTORY_FILE = join(DATA_DIR, 'history.json');
const PROMPTS_FILE = join(DATA_DIR, 'prompts.json');
const MAX_HISTORY = 500;
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);
}
// Ensure data directory exists
await mkdir(DATA_DIR, { recursive: true });
// Load prompts
try {
const customPromptsStr = await readFile(PROMPTS_FILE, 'utf-8');
const customPrompts = JSON.parse(customPromptsStr);
setActivePrompts(customPrompts);
console.log('[startup] Custom system prompts loaded');
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('[startup] Failed to read prompts.json:', err.message);
}
}
console.log(`[startup] Model: ${LLM.model}`);
console.log(`[startup] Endpoint: ${LLM.endpoint}`);
console.log(`[startup] Data dir: ${DATA_DIR}`);
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// ──────────────────────────────────────────────────────────────────────────────
// History helpers
// ──────────────────────────────────────────────────────────────────────────────
let historyCache = null;
async function readHistory() {
if (historyCache !== null) return historyCache;
try {
const raw = await readFile(HISTORY_FILE, 'utf-8');
const parsed = JSON.parse(raw);
historyCache = Array.isArray(parsed) ? parsed : [];
} catch {
historyCache = [];
}
return historyCache;
}
async function writeHistory(entries) {
historyCache = entries;
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
}
// ──────────────────────────────────────────────────────────────────────────────
// 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();
let content = data.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error('Provider response did not include a message');
}
// Strip <think> blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1)
content = content.replace(/<think>[\s\S]*?(?:<\/think>\s*|$)/gi, '').trim();
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,
});
}
function handleConfig(req, res) {
let videoAiLinks = [];
const envLinks = process.env.VIDEO_AI_LINKS || '';
if (envLinks.trim()) {
// Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai"
videoAiLinks = envLinks.split(',').map(part => {
const sep = part.includes('=') ? '=' : '|';
const idx = part.indexOf(sep);
if (idx > 0) {
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
}
return null;
}).filter(link => link !== null);
}
json(res, { videoAiLinks });
}
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 idea = typeof body?.idea === 'string' ? body.idea.trim() : '';
const signal = combinedSignal(req);
const userMsg = idea
? `Generate one style description now. (User's idea: "${idea}")`
: 'Generate one style description now.';
try {
const content = await callProvider(
[
{ role: 'system', content: buildStylePrompt(mode, idea) },
{ role: 'user', content: userMsg },
],
{ max_tokens: 8192 },
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;
}
console.error('[style/random]', err);
json(res, { error: err.message || 'Failed to generate style' }, 500);
}
}
async function handleGetHistory(req, res) {
try {
const entries = await readHistory();
json(res, entries);
} catch (err) {
console.error('[history/get]', err);
json(res, { error: 'Failed to read history' }, 500);
}
}
async function handleAddHistory(req, res) {
let body;
try { body = await readJsonBody(req); }
catch (err) { return json(res, { error: err.message }, 400); }
if (!body || typeof body.id !== 'string' || !body.input || !body.assets) {
return json(res, { error: 'Invalid history entry' }, 400);
}
try {
const entries = await readHistory();
// Deduplicate by id
const filtered = entries.filter(e => e.id !== body.id);
const next = [body, ...filtered].slice(0, MAX_HISTORY);
await writeHistory(next);
json(res, { ok: true });
} catch (err) {
console.error('[history/add]', err);
json(res, { error: 'Failed to save history' }, 500);
}
}
async function handleDeleteHistory(req, res, id) {
try {
const entries = await readHistory();
const next = entries.filter(e => e.id !== id);
await writeHistory(next);
json(res, { ok: true });
} catch (err) {
console.error('[history/delete]', err);
json(res, { error: 'Failed to delete entry' }, 500);
}
}
async function handleClearHistory(req, res) {
try {
await writeHistory([]);
json(res, { ok: true });
} catch (err) {
console.error('[history/clear]', err);
json(res, { error: 'Failed to clear history' }, 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);
const MAX_ATTEMPTS = 2;
let lastErr;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
if (signal.aborted) break;
try {
const content = await callProvider(
[
{ role: 'system', content: buildSystemPrompt(body) },
{ role: 'user', content: buildUserMessage(body) },
],
{ max_tokens: 32768 },
signal,
);
if (body.section === 'lyrics_partial') {
return json(res, { lyrics: content });
}
const parsed = extractJson(content);
if (body.section === 'all') {
validateFullAssets(parsed);
} else {
validateShape(parsed);
}
const result = body.section === 'all' ? parsed : pickResultForSection(body.section, parsed);
return json(res, result);
} catch (err) {
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
return;
}
lastErr = err;
if (attempt < MAX_ATTEMPTS) {
console.warn(`[generate] attempt ${attempt} failed, retrying… (${err.message})`);
}
}
}
console.error('[generate]', lastErr);
json(res, { error: lastErr?.message || 'Generation failed' }, 500);
}
// ──────────────────────────────────────────────────────────────────────────────
// System Prompts Config Handlers
// ──────────────────────────────────────────────────────────────────────────────
import { activePrompts } from './server/prompts.mjs';
import { unlink } from 'node:fs/promises';
function handleGetSystemPrompts(req, res) {
json(res, activePrompts);
}
async function handlePostSystemPrompts(req, res) {
try {
const body = await parseJsonBody(req);
// Merge with defaults to ensure all keys exist
const newPrompts = { ...DEFAULT_PROMPTS, ...body };
setActivePrompts(newPrompts);
await writeFile(PROMPTS_FILE, JSON.stringify(newPrompts, null, 2), 'utf-8');
json(res, { success: true });
} catch (err) {
console.error('[prompts] failed to save', err);
json(res, { error: 'Failed to save prompts' }, 500);
}
}
async function handleDeleteSystemPrompts(req, res) {
try {
try {
await unlink(PROMPTS_FILE);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
setActivePrompts(DEFAULT_PROMPTS);
json(res, { success: true });
} catch (err) {
console.error('[prompts] failed to delete', err);
json(res, { error: 'Failed to restore default prompts' }, 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 === 'GET' && req.url.split('?')[0] === '/api/config') {
return handleConfig(req, res);
}
// System Prompts endpoints
if (req.method === 'GET' && req.url.split('?')[0] === '/api/system-prompts') {
return handleGetSystemPrompts(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/system-prompts') {
return handlePostSystemPrompts(req, res);
}
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/system-prompts') {
return handleDeleteSystemPrompts(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);
}
// History endpoints
if (req.method === 'GET' && req.url.split('?')[0] === '/api/history') {
return handleGetHistory(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/history') {
return handleAddHistory(req, res);
}
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/history') {
return handleClearHistory(req, res);
}
const deleteMatch = req.method === 'DELETE' && req.url.match(/^\/api\/history\/([^/?]+)/);
if (deleteMatch) {
return handleDeleteHistory(req, res, decodeURIComponent(deleteMatch[1]));
}
// 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();
});
}