diff --git a/README.md b/README.md index 52dc286..6edf7c7 100644 --- a/README.md +++ b/README.md @@ -203,12 +203,11 @@ without losing the original. ### Recent generations -The last 6 successful generations are saved in `localStorage` under -`melodymuse-history`. Click any entry to restore both the **input form +The last 500 successful generations are saved on the server. Click any entry to restore both the **input form values** AND the full **generated assets** — handy for comparing two generations of the same idea. -Individual entries have an ✕ button to remove them. There's a **Clear all** +Individual entries have an ✕ button to remove them. There's a **Clear history** button at the bottom of the history panel. ### Download the ZIP @@ -393,6 +392,43 @@ provider, the key, or the prompts. | `LLM_MODEL` | | `MiniMax-M3` | | | `PORT` | | `3000` | | | `CORS_ORIGIN` | | `*` | Lock this down in production | +| `DATA_DIR` | | `data` | Directory for persistent storage (e.g. `history.json`) | + +### Docker and History Persistence + +The application saves recent generations (history) to a file named `history.json` inside the directory specified by the `DATA_DIR` environment variable (defaults to `data` in the project root). + +If you are running the application using Docker, any files written inside the container will be lost when the container is rebuilt or restarted. To ensure your history survives a Docker rebuild, you **must set up a Docker volume** mapped to the `DATA_DIR`. + +**Example using `docker run`:** +```sh +docker run -d \ + -p 3000:3000 \ + -e LLM_ENDPOINT="https://api.minimax.chat/v1" \ + -e LLM_API_KEY="sk-..." \ + -e DATA_DIR="/app/data" \ + -v melodymuse-data:/app/data \ + melodymuse-image +``` + +**Example using `docker-compose.yml`:** +```yaml +services: + melodymuse: + image: melodymuse-image + ports: + - "3000:3000" + environment: + - LLM_ENDPOINT=https://api.minimax.chat/v1 + - LLM_API_KEY=sk-... + - DATA_DIR=/app/data + volumes: + - melodymuse-data:/app/data + +volumes: + melodymuse-data: +``` +This ensures the `history.json` file is securely stored on your host machine and persists across rebuilds. ### Environment variables (Vite, dev only) @@ -435,7 +471,7 @@ Response: `{ "style": "…", "mode": "…" }`. - The API key is held by the Node process via `process.env`. It's never sent to the browser in any response, not even in the health check. - The `localStorage` keys the SPA writes: - - `melodymuse-history` — last 6 generations + - `melodymuse-draft` — current unsaved input draft - `melodymuse-theme` — `'dark'` or `'light'` - `melodymuse-config` — reserved, currently unused (kept for future client-side server-URL override) diff --git a/server.mjs b/server.mjs index 564a986..f07d230 100644 --- a/server.mjs +++ b/server.mjs @@ -18,7 +18,7 @@ // (default: "*", fine for single-user personal use) import { createServer } from 'node:http'; -import { readFile, stat } from 'node:fs/promises'; +import { readFile, writeFile, stat, mkdir } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -36,6 +36,9 @@ 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 MAX_HISTORY = 500; const LLM = { endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''), @@ -52,10 +55,32 @@ if (!LLM.endpoint || !LLM.apiKey) { process.exit(1); } +// Ensure data directory exists +await mkdir(DATA_DIR, { recursive: true }); + console.log(`[startup] LLM endpoint: ${LLM.endpoint}`); console.log(`[startup] LLM model: ${LLM.model}`); +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 // ────────────────────────────────────────────────────────────────────────────── @@ -330,13 +355,18 @@ async function handleStyleRandom(req, res) { 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) }, - { role: 'user', content: 'Generate one style description now.' }, + { role: 'system', content: buildStylePrompt(mode, idea) }, + { role: 'user', content: userMsg }, ], { max_tokens: 120 }, signal, @@ -346,13 +376,67 @@ async function handleStyleRandom(req, res) { json(res, { style, mode }); } catch (err) { if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) { - return; // Client gave up; no point responding. + 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); } @@ -430,6 +514,20 @@ const server = createServer(async (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/')) { diff --git a/server/prompts.mjs b/server/prompts.mjs index b3942aa..f26b3ab 100644 --- a/server/prompts.mjs +++ b/server/prompts.mjs @@ -91,6 +91,7 @@ Output ONE short Suno style description. Requirements: - A cohesive, production-ready combination of genres, instruments, BPM, mood, and vocal style (or "instrumental, no vocals" if appropriate). +- If the user provides a music idea, tailor the style to complement it naturally. - Comma-separated keywords. No sentences, no bullet points, no labels. - Maximum 25 words. - Output ONLY the style description line — no quotes, no commentary, no prefix, @@ -108,6 +109,8 @@ Output ONE short Suno style description. Requirements: - DELIBERATELY combine genres, eras, or instruments that don't normally mix. +- If the user provides a music idea, use it as a jumping-off point for an + unexpected, surprising twist on that idea. - The combination should still be parseable by Suno: use real instrument and genre names, include a BPM, mention a vocal style. - Comma-separated keywords. No sentences, no bullet points, no labels. @@ -122,12 +125,40 @@ Examples of the expected output (do NOT copy these verbatim): - medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM - tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`; +export const SYSTEM_PROMPT_LYRICS_PARTIAL = `You are a music production assistant specializing in Suno AI song creation. + +The user has existing lyrics and wants to regenerate a SPECIFIC selected passage only. + +Rules: +- Rewrite ONLY the selected passage. Keep everything outside it exactly the same. +- Match the existing rhyme scheme, meter, and section structure. +- Keep the same emotional tone, language, and Suno section tags. +- The lyrics must NOT reference the genre, instruments, or music production. +- Output ONLY the rewritten passage text — no JSON, no explanation, no quotes around it. +- Do NOT include section tags in your output unless the selected passage already contained them.`; + export function buildSystemPrompt(req) { + if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL; if (req.section === 'all') return SYSTEM_PROMPT_ALL; return SYSTEM_PROMPT_PARTIAL; } export function buildUserMessage(req) { + if (req.section === 'lyrics_partial') { + return [ + `Music idea: ${req.input}`, + `Language: ${req.language}`, + req.mood ? `Mood: ${req.mood}` : '', + req.vocals ? `Vocals: ${req.vocals}` : '', + '', + 'Full current lyrics:', + req.context?.lyrics ?? '', + '', + 'Selected passage to rewrite:', + req.selected_text ?? '', + ].filter(l => l !== null).join('\n'); + } + const lines = []; lines.push(`Music idea: ${req.input}`); lines.push(`Language: ${req.language}`); @@ -145,6 +176,8 @@ export function buildUserMessage(req) { return lines.join('\n'); } -export function buildStylePrompt(mode) { - return mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL; +export function buildStylePrompt(mode, idea) { + const base = mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL; + if (!idea || !idea.trim()) return base; + return base + `\n\nUser's music idea (use as context for tailoring the style): "${idea.trim()}"`; } diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index a55c52c..4116c58 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faClock, faHistory, faTrashAlt, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { faClock, faHistory, faTrashAlt, faTimes, faCircleNotch } from "@fortawesome/free-solid-svg-icons"; import { clearHistory, formatRelative, @@ -20,16 +20,26 @@ interface HistoryDrawerProps { export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) { const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); const { t } = useI18n(); - useEffect(() => { - setEntries(loadHistory()); - }, []); + const fetchHistory = async () => { + setLoading(true); + try { + const data = await loadHistory(); + setEntries(data); + } finally { + setLoading(false); + } + }; useEffect(() => { - const handler = () => setEntries(loadHistory()); - window.addEventListener(HISTORY_UPDATED_EVENT, handler); - return () => window.removeEventListener(HISTORY_UPDATED_EVENT, handler); + if (open) fetchHistory(); + }, [open]); + + useEffect(() => { + window.addEventListener(HISTORY_UPDATED_EVENT, fetchHistory); + return () => window.removeEventListener(HISTORY_UPDATED_EVENT, fetchHistory); }, []); // Close on Escape @@ -65,24 +75,27 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) { {t.history} - {entries.length > 0 && ( + {!loading && entries.length > 0 && ( {entries.length} )} - +
+ {loading && } + +
{/* Entries */}
- {entries.length === 0 ? ( + {entries.length === 0 && !loading ? (

{t.noHistory}

) : ( entries.map((e) => ( @@ -90,7 +103,9 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) { key={e.id} entry={e} onLoad={() => { onLoad(e); onClose(); }} - onRemove={() => setEntries(removeFromHistory(e.id))} + onRemove={async () => { + await removeFromHistory(e.id); + }} /> )) )} @@ -101,11 +116,16 @@ export function HistoryDrawer({ open, onClose, onLoad }: HistoryDrawerProps) {
)} diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx index d6b2193..1f5511b 100644 --- a/src/components/InputPanel.tsx +++ b/src/components/InputPanel.tsx @@ -3,7 +3,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleNotch, faWandMagicSparkles, - faShuffle, faFire, faMicrophone, faMusic, @@ -89,42 +88,65 @@ export function InputPanel({ } }; - const fillExample = () => { - const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)]; - onChange({ ...values, idea: ex.idea, mood: values.mood || ex.mood, vocals: ex.vocals }); - }; - - // "Surprise me" fills both idea AND style with a coherent pair + // "Surprise me" fills style. If idea is empty, fills a random idea too. const handleSurpriseMe = async () => { if (styleLoading) return; - // Try to get a random style from the server; also fill idea locally + const hasIdea = values.idea.trim().length > 0; const ex = SURPRISE_PAIRS[Math.floor(Math.random() * SURPRISE_PAIRS.length)]; + const ideaToUse = hasIdea ? values.idea : ex.idea; + const ctrl = new AbortController(); setStyleLoading("normal"); try { - const style = await randomStyle("normal", ctrl.signal); - onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals }); + const style = await randomStyle("normal", ctrl.signal, ideaToUse); + onChange({ + ...values, + idea: ideaToUse, + style_hint: style, + mood: hasIdea ? values.mood : ex.mood, + vocals: hasIdea ? values.vocals : ex.vocals + }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - // Fallback to local style if server fails - onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals }); + onChange({ + ...values, + idea: ideaToUse, + style_hint: hasIdea ? values.style_hint : ex.style, + mood: hasIdea ? values.mood : ex.mood, + vocals: hasIdea ? values.vocals : ex.vocals + }); } finally { setStyleLoading((curr) => (curr === "normal" ? null : curr)); } }; - // "Go crazy" fills both idea AND style with a wild pair + // "Go crazy" fills style wildly. If idea is empty, fills a wild idea too. const handleGoCrazy = async () => { if (styleLoading) return; + const hasIdea = values.idea.trim().length > 0; const ex = CRAZY_PAIRS[Math.floor(Math.random() * CRAZY_PAIRS.length)]; + const ideaToUse = hasIdea ? values.idea : ex.idea; + const ctrl = new AbortController(); setStyleLoading("crazy"); try { - const style = await randomStyle("crazy", ctrl.signal); - onChange({ ...values, idea: ex.idea, style_hint: style, mood: ex.mood, vocals: ex.vocals }); + const style = await randomStyle("crazy", ctrl.signal, ideaToUse); + onChange({ + ...values, + idea: ideaToUse, + style_hint: style, + mood: hasIdea ? values.mood : ex.mood, + vocals: hasIdea ? values.vocals : ex.vocals + }); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; - onChange({ ...values, idea: ex.idea, style_hint: ex.style, mood: ex.mood, vocals: ex.vocals }); + onChange({ + ...values, + idea: ideaToUse, + style_hint: hasIdea ? values.style_hint : ex.style, + mood: hasIdea ? values.mood : ex.mood, + vocals: hasIdea ? values.vocals : ex.vocals + }); } finally { setStyleLoading((curr) => (curr === "crazy" ? null : curr)); } @@ -151,15 +173,7 @@ export function InputPanel({ /> {/* Example / Surprise / Crazy buttons */}
- + {/* Removed Try an Example button */} )} {assets && ( @@ -72,7 +74,7 @@ export function ResultsPanel({ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - Regenerate all + {t.regenerateAll} )}
@@ -100,7 +102,7 @@ export function ResultsPanel({ lyrics={assets.lyrics} originalLyrics={originalAssets.lyrics} onChange={(v) => onChange("lyrics", v)} - onRegenerate={() => onRegenerateSection("lyrics")} + onRegenerate={(selectedText) => onRegenerateSection("lyrics", selectedText)} regenerating={Boolean(regenerating.lyrics)} />
@@ -144,13 +147,13 @@ function EmptyState() {
-

Your song will appear here

+

{t.emptyStateTitle}

- Describe a music idea on the left and press{" "} + {t.emptyStateBody}{" "} - Generate + {t.generateAssets} {" "} - to create titles, lyrics, style, video prompts, and a YouTube description. + {t.emptyStateBody2}

); diff --git a/src/components/cards/LyricsCard.tsx b/src/components/cards/LyricsCard.tsx index 539f52b..5570fe8 100644 --- a/src/components/cards/LyricsCard.tsx +++ b/src/components/cards/LyricsCard.tsx @@ -3,12 +3,13 @@ import { CopyButton } from "../CopyButton"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFileAlt, faUndo } from "@fortawesome/free-solid-svg-icons"; import { useAutoHeight } from "../../lib/useAutoHeight"; +import { useI18n } from "../../lib/i18n"; interface LyricsCardProps { lyrics: string; originalLyrics: string; onChange: (next: string) => void; - onRegenerate: () => void; + onRegenerate: (selectedText?: string) => void; regenerating: boolean; } @@ -21,13 +22,25 @@ export function LyricsCard({ }: LyricsCardProps) { const ref = useAutoHeight(lyrics, 200); const dirty = lyrics !== originalLyrics; + const { t } = useI18n(); + + const handleRegenerate = () => { + let selected = ""; + if (ref.current) { + selected = ref.current.value.substring( + ref.current.selectionStart, + ref.current.selectionEnd + ).trim(); + } + onRegenerate(selected || undefined); + }; return ( } - title="Lyrics" - onRegenerate={onRegenerate} + title={t.lyrics} + onRegenerate={handleRegenerate} regenerating={regenerating} headerExtra={ <> @@ -37,25 +50,32 @@ export function LyricsCard({ onClick={() => onChange(originalLyrics)} className="btn-ghost" aria-label="Revert lyrics to last generated version" - title="Revert to last generated" + title={t.revert} > - Revert + {t.revert} )} - {lyrics && } + {lyrics && } } > -