// 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 || ''}`); console.error(` LLM_API_KEY: ${LLM.apiKey ? '********' : ''}`); 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 // ────────────────────────────────────────────────────────────────────────────── async function readHistory() { try { const raw = await readFile(HISTORY_FILE, 'utf-8'); const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } async function writeHistory(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 blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1) content = content.replace(/[\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 idx = part.indexOf('='); 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: 800 }, 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: 4000 }, 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(); }); }