Compare commits

..
21 Commits
Author SHA1 Message Date
Jannik d157d34fca Optimize frontend rendering, CSS, and backend history caching 2026-07-04 23:14:47 +02:00
Jannik b888d3d3fb Auto-Commit: 2026-07-04 22:51:07 2026-07-04 22:51:07 +02:00
Jannik fa05cfded7 fix: increase max_tokens for Minimax-M3 to prevent truncated JSON 2026-06-15 19:41:51 +02:00
Jannik 5e16a7049f chore: remove hardcoded api keys from minimax test scripts 2026-06-14 11:14:39 +02:00
Jannik 1abd2d1d59 Auto-Commit: 2026-06-13 15:16:41 2026-06-13 15:16:41 +02:00
Jannik ad326c7efa chore: update .gitignore with temp files and venv 2026-06-13 14:10:18 +02:00
Jannik 6c1a8cd281 feat: add chat ai link to header 2026-06-04 20:27:41 +02:00
Jannik ade33edc6d feat: visually highlight the recommended video ai button 2026-06-04 20:20:05 +02:00
Jannik d7aa7a349a fix: tool recommendation and lyrics text selection 2026-06-04 13:48:26 +02:00
Jannik 0932357cdf fix: normalize newlines for lyrics replacement 2026-06-04 13:25:53 +02:00
Jannik cee3d24582 style: fix custom cursor on labels 2026-06-04 12:56:55 +02:00
Jannik b1b77c1714 style: make settings page wider for better prompt editing 2026-06-04 12:54:15 +02:00
Jannik cba6a173e6 chore: user tweaks to system prompts 2026-06-04 12:51:17 +02:00
Jannik 5bc3b6bbcb feat: improve video AI tool links UX and copy prompt on click 2026-06-04 12:49:01 +02:00
Jannik 8e36ec5ca5 fix: add missing hooks and fix type error 2026-06-04 11:53:00 +02:00
Jannik f4cb318175 feat: add editable system prompts from web UI 2026-06-04 11:31:56 +02:00
Jannik ecd75a7fb5 fix: dynamically suggest video tools based on env configuration 2026-06-04 11:21:23 +02:00
Jannik ab5a967fe4 feat: add configurable video AI links 2026-06-04 11:11:36 +02:00
Jannik 9dddaffdd4 fix: resolve 500 error on partial lyrics regeneration and fix mobile scrolling by removing overflow: hidden 2026-06-04 10:51:48 +02:00
Jannik 0353a5a9af fix: increase max_tokens and handle unclosed think blocks for style generation 2026-06-04 08:50:58 +02:00
Jannik 5dae92f137 UI: Change Suno hover color to orange 2026-06-04 00:52:15 +02:00
17 changed files with 814 additions and 114 deletions
+3
View File
@@ -108,3 +108,6 @@ CORS_ORIGIN=*
# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want # Leave empty to use the dev proxy (see vite.config.ts). Set this if you want
# the browser to talk to a server on a different origin. # the browser to talk to a server on a different origin.
# VITE_API_BASE_URL=http://localhost:3000 # VITE_API_BASE_URL=http://localhost:3000
# Video AI Links configuration (comma-separated Key=URL pairs)
VIDEO_AI_LINKS="Kling AI=https://klingai.com,Luma=https://lumalabs.ai,Runway=https://runwayml.com"
+9
View File
@@ -33,3 +33,12 @@ lerna-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# python / environments
.venv/
venv/
# temporary files
temp.txt
.tmp
scratch/
+103 -7
View File
@@ -26,6 +26,8 @@ import {
buildStylePrompt, buildStylePrompt,
buildSystemPrompt, buildSystemPrompt,
buildUserMessage, buildUserMessage,
setActivePrompts,
DEFAULT_PROMPTS,
} from './server/prompts.mjs'; } from './server/prompts.mjs';
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -38,6 +40,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data'); const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
const HISTORY_FILE = join(DATA_DIR, 'history.json'); const HISTORY_FILE = join(DATA_DIR, 'history.json');
const PROMPTS_FILE = join(DATA_DIR, 'prompts.json');
const MAX_HISTORY = 500; const MAX_HISTORY = 500;
const LLM = { const LLM = {
@@ -58,8 +61,20 @@ if (!LLM.endpoint || !LLM.apiKey) {
// Ensure data directory exists // Ensure data directory exists
await mkdir(DATA_DIR, { recursive: true }); await mkdir(DATA_DIR, { recursive: true });
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`); // Load prompts
console.log(`[startup] LLM model: ${LLM.model}`); 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] Data dir: ${DATA_DIR}`);
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`); console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
@@ -67,17 +82,22 @@ console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// History helpers // History helpers
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
let historyCache = null;
async function readHistory() { async function readHistory() {
if (historyCache !== null) return historyCache;
try { try {
const raw = await readFile(HISTORY_FILE, 'utf-8'); const raw = await readFile(HISTORY_FILE, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : []; historyCache = Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return []; historyCache = [];
} }
return historyCache;
} }
async function writeHistory(entries) { async function writeHistory(entries) {
historyCache = entries;
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8'); await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
} }
@@ -210,7 +230,7 @@ async function callProvider(messages, opts = {}, signal) {
} }
// Strip <think> blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1) // Strip <think> blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1)
content = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '').trim(); content = content.replace(/<think>[\s\S]*?(?:<\/think>\s*|$)/gi, '').trim();
return content; return content;
} }
@@ -349,6 +369,24 @@ async function handleHealth(req, res) {
}); });
} }
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) { async function handleStyleRandom(req, res) {
let body; let body;
try { body = await readJsonBody(req); } try { body = await readJsonBody(req); }
@@ -368,7 +406,7 @@ async function handleStyleRandom(req, res) {
{ role: 'system', content: buildStylePrompt(mode, idea) }, { role: 'system', content: buildStylePrompt(mode, idea) },
{ role: 'user', content: userMsg }, { role: 'user', content: userMsg },
], ],
{ max_tokens: 120 }, { max_tokens: 8192 },
signal, signal,
); );
const style = cleanStyleString(content); const style = cleanStyleString(content);
@@ -462,10 +500,14 @@ async function handleGenerate(req, res) {
{ role: 'system', content: buildSystemPrompt(body) }, { role: 'system', content: buildSystemPrompt(body) },
{ role: 'user', content: buildUserMessage(body) }, { role: 'user', content: buildUserMessage(body) },
], ],
{ max_tokens: 4000 }, { max_tokens: 32768 },
signal, signal,
); );
if (body.section === 'lyrics_partial') {
return json(res, { lyrics: content });
}
const parsed = extractJson(content); const parsed = extractJson(content);
if (body.section === 'all') { if (body.section === 'all') {
@@ -491,6 +533,45 @@ async function handleGenerate(req, res) {
json(res, { error: lastErr?.message || 'Generation failed' }, 500); 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 // Server
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
@@ -508,6 +589,21 @@ const server = createServer(async (req, res) => {
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') { if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
return handleHealth(req, res); 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') { if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
return handleGenerate(req, res); return handleGenerate(req, res);
} }
+77 -45
View File
@@ -1,7 +1,8 @@
// System + user prompt construction for the LLM. Server-side only — the // System + user prompt construction for the LLM. Server-side only — the
// browser never sees these prompts. // browser never sees these prompts.
export const SYSTEM_PROMPT_ALL = `You are a music production assistant specializing in Suno AI song creation. export const DEFAULT_PROMPTS = {
all: `You are a music production assistant specializing in Suno AI song creation.
The user will describe a music idea. Generate all song assets as a single JSON The user will describe a music idea. Generate all song assets as a single JSON
object. Output ONLY valid JSON — no markdown, no backticks, no preamble. object. Output ONLY valid JSON — no markdown, no backticks, no preamble.
@@ -32,27 +33,24 @@ REQUIRED JSON STRUCTURE:
"video_prompts": [ "video_prompts": [
{ {
"type": "Abstract", "type": "Abstract",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason why they suit this prompt" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Cinematic", "type": "Cinematic",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Hybrid", "type": "Hybrid",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
} }
] ]
} }
Keep each video_prompts[].prompt under 900 characters (excluding the Negative line). Keep each video_prompts[].prompt under 900 characters (excluding the Negative line).`,
Recommended video tools (choose the best fit per prompt): Runway Gen-3 Alpha, Kling AI, partial: `You are a music production assistant specializing in Suno AI song creation.
Luma Dream Machine, Pika 2.0, Haiper. Never recommend Sora.`;
const SYSTEM_PROMPT_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
The user has an existing song and wants to regenerate ONE section. Output ONLY The user has an existing song and wants to regenerate ONE section. Output ONLY
valid JSON — no markdown, no backticks, no preamble. valid JSON — no markdown, no backticks, no preamble.
@@ -79,12 +77,11 @@ QUALITY CONSTRAINTS:
ABAB rhyme. No references to instruments, genre, or music production. ABAB rhyme. No references to instruments, genre, or music production.
- style: comma-separated, max 120 words, include tempo + BPM range, never - style: comma-separated, max 120 words, include tempo + BPM range, never
name an artist, no "sounds like" phrases. name an artist, no "sounds like" phrases.
- video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line - video_prompts: short seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters. inside each prompt. Keep prompts under 900 characters. Recommend a tool from: {{VIDEO_TOOLS}}.
- youtube_description: follow the hook → description → divider → lyrics → - youtube_description: follow the hook → description → divider → lyrics →
divider → CTA → hashtags structure.`; divider → CTA → hashtags structure.`,
style_normal: `You are a Suno style-prompt generator.
export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator.
Output ONE short Suno style description. Output ONE short Suno style description.
@@ -101,9 +98,8 @@ Examples of the expected output (do NOT copy these verbatim):
- dark synthwave, analog pads, driving bassline, 110 BPM, male vocals - dark synthwave, analog pads, driving bassline, 110 BPM, male vocals
- indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM - indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM
- trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals - trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals
- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`; - dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`,
style_crazy: `You are a Suno style-prompt generator with permission to break conventions.
export const SYSTEM_PROMPT_STYLE_CRAZY = `You are a Suno style-prompt generator with permission to break conventions.
Output ONE short Suno style description. Output ONE short Suno style description.
@@ -123,9 +119,8 @@ Examples of the expected output (do NOT copy these verbatim):
- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM - baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle - lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM - medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`; - tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`,
lyrics_partial: `You are a music production assistant specializing in Suno AI song creation.
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. The user has existing lyrics and wants to regenerate a SPECIFIC selected passage only.
@@ -135,12 +130,44 @@ Rules:
- Keep the same emotional tone, language, and Suno section tags. - Keep the same emotional tone, language, and Suno section tags.
- The lyrics must NOT reference the genre, instruments, or music production. - 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. - 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.`; - Do NOT include section tags in your output unless the selected passage already contained them.`
};
export let activePrompts = { ...DEFAULT_PROMPTS };
export function setActivePrompts(prompts) {
activePrompts = { ...DEFAULT_PROMPTS, ...prompts };
}
function getToolsString() {
let tools = 'Runway Gen-3 Alpha, Kling AI, Haiper';
const envLinks = process.env.VIDEO_AI_LINKS || '';
if (envLinks.trim()) {
const names = envLinks.split(',').map(part => {
const idx = part.indexOf('=');
return idx > 0 ? part.slice(0, idx).trim() : null;
}).filter(Boolean);
if (names.length > 0) {
tools = names.join(', ');
}
}
return tools;
}
export function buildSystemPrompt(req) { export function buildSystemPrompt(req) {
if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL; const toolsStr = getToolsString();
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return SYSTEM_PROMPT_PARTIAL; if (req.section === 'lyrics_partial') {
return activePrompts.lyrics_partial;
}
if (req.section === 'all') {
return activePrompts.all.replace('{{VIDEO_TOOLS}}', toolsStr);
}
return activePrompts.partial.replace('{{VIDEO_TOOLS}}', toolsStr);
}
export function buildStylePrompt(crazy) {
return crazy ? activePrompts.style_crazy : activePrompts.style_normal;
} }
export function buildUserMessage(req) { export function buildUserMessage(req) {
@@ -156,28 +183,33 @@ export function buildUserMessage(req) {
'', '',
'Selected passage to rewrite:', 'Selected passage to rewrite:',
req.selected_text ?? '', req.selected_text ?? '',
].filter(l => l !== null).join('\n'); ]
.filter((line) => line !== null)
.join('\n');
} }
const lines = []; const lines = [
lines.push(`Music idea: ${req.input}`); `Music idea: ${req.input}`,
lines.push(`Language: ${req.language}`); `Language: ${req.language}`,
if (req.mood) lines.push(`Mood: ${req.mood}`); req.mood ? `Mood: ${req.mood}` : '',
if (req.style_hint) lines.push(`Style hint (user-provided): ${req.style_hint}`); req.vocals ? `Vocals: ${req.vocals}` : '',
lines.push(`Vocals: ${req.vocals}`); ];
lines.push(`Section to generate: ${req.section}`);
if (req.context && Object.keys(req.context).length > 0) { if (req.section !== 'all' && req.context) {
lines.push(''); lines.push('');
lines.push('Context — the current values of the other sections of this song:'); lines.push('CURRENT STATE OF OTHER SECTIONS:');
lines.push(JSON.stringify(req.context, null, 2)); if (req.context.titles) {
lines.push('Titles:', ...req.context.titles.map((t) => `- ${t}`));
}
if (req.context.style) {
lines.push(`Style: ${req.context.style}`);
}
if (req.context.lyrics) {
lines.push('Lyrics:', req.context.lyrics);
}
lines.push('');
lines.push(`Please regenerate ONLY the following section: ${req.section}`);
} }
return lines.join('\n'); return lines.filter((line) => line !== null).join('\n');
}
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()}"`;
} }
+6 -2
View File
@@ -1,20 +1,24 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { SettingsPage } from './pages/SettingsPage';
import { ToastProvider } from './lib/toast'; import { ToastProvider } from './lib/toast';
import { I18nProvider } from './lib/i18n'; import { I18nProvider } from './lib/i18n';
import { ThemeProvider } from './lib/theme'; import { ThemeProvider } from './lib/theme';
const HomePage = lazy(() => import('./pages/HomePage').then(module => ({ default: module.HomePage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(module => ({ default: module.SettingsPage })));
export default function App() { export default function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<I18nProvider> <I18nProvider>
<ToastProvider> <ToastProvider>
<Suspense fallback={<div className="h-screen w-screen flex items-center justify-center bg-bg"><div className="w-8 h-8 rounded-full border-4 border-accent-primary border-t-transparent animate-spin" /></div>}>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<HomePage />} /> <Route path="*" element={<HomePage />} />
</Routes> </Routes>
</Suspense>
</ToastProvider> </ToastProvider>
</I18nProvider> </I18nProvider>
</ThemeProvider> </ThemeProvider>
+35 -13
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent, type KeyboardEvent } from "react"; import { useState, useEffect, type FormEvent, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faCircleNotch, faCircleNotch,
@@ -51,7 +51,8 @@ const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }
]; ];
interface InputPanelProps { interface InputPanelProps {
values: InputValues; initialValues: InputValues;
forcedUpdate?: { count: number; values: InputValues };
onChange: (next: InputValues) => void; onChange: (next: InputValues) => void;
onSubmit: () => void; onSubmit: () => void;
loading: boolean; loading: boolean;
@@ -60,19 +61,32 @@ interface InputPanelProps {
} }
export function InputPanel({ export function InputPanel({
values, initialValues,
forcedUpdate,
onChange, onChange,
onSubmit, onSubmit,
loading, loading,
disabled, disabled,
cancelButton, cancelButton,
}: InputPanelProps) { }: InputPanelProps) {
const [values, setValues] = useState<InputValues>(initialValues);
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null); const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
const elapsed = useElapsed(loading); const elapsed = useElapsed(loading);
const { t } = useI18n(); const { t } = useI18n();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => // Handle external updates (e.g. from loading history)
onChange({ ...values, [key]: value }); useEffect(() => {
if (forcedUpdate && forcedUpdate.count > 0) {
setValues(forcedUpdate.values);
onChange(forcedUpdate.values);
}
}, [forcedUpdate, onChange]);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => {
const next = { ...values, [key]: value };
setValues(next);
onChange(next);
};
const canSubmit = !loading && !disabled && values.idea.trim().length > 0; const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
@@ -99,22 +113,26 @@ export function InputPanel({
setStyleLoading("normal"); setStyleLoading("normal");
try { try {
const style = await randomStyle("normal", ctrl.signal, ideaToUse); const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: style, style_hint: style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style, style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} finally { } finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr)); setStyleLoading((curr) => (curr === "normal" ? null : curr));
} }
@@ -131,22 +149,26 @@ export function InputPanel({
setStyleLoading("crazy"); setStyleLoading("crazy");
try { try {
const style = await randomStyle("crazy", ctrl.signal, ideaToUse); const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: style, style_hint: style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style, style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} finally { } finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr)); setStyleLoading((curr) => (curr === "crazy" ? null : curr));
} }
+70 -8
View File
@@ -2,13 +2,16 @@ import { useState } from "react";
import { ResultCard } from "../ResultCard"; import { ResultCard } from "../ResultCard";
import { CopyButton } from "../CopyButton"; import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faVideo, faUndo } from "@fortawesome/free-solid-svg-icons"; import { faVideo, faUndo, faStar } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight"; import { useAutoHeight } from "../../lib/useAutoHeight";
import { import {
NEGATIVE_VIDEO_PROMPT, NEGATIVE_VIDEO_PROMPT,
type VideoPrompt, type VideoPrompt,
type VideoPromptType, type VideoPromptType,
} from "../../lib/types"; } from "../../lib/types";
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../../lib/toast";
interface VideoPromptsCardProps { interface VideoPromptsCardProps {
prompts: VideoPrompt[]; prompts: VideoPrompt[];
@@ -34,6 +37,37 @@ export function VideoPromptsCard({
const dirty = current?.prompt !== originalCurrent?.prompt; const dirty = current?.prompt !== originalCurrent?.prompt;
const ref = useAutoHeight(current?.prompt ?? "", 120); const ref = useAutoHeight(current?.prompt ?? "", 120);
const { links } = useVideoAiLinks();
const toast = useToast();
const handleToolClick = async (url: string) => {
if (!current) return;
// Copy the prompt to clipboard (with negative prompt if applicable)
const textToCopy = `Prompt: ${current.prompt}\n\nNegative: ${NEGATIVE_VIDEO_PROMPT}`;
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Prompt copied to clipboard!");
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = textToCopy;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
toast.success("Prompt copied to clipboard!");
} catch {
toast.error("Failed to copy prompt");
}
document.body.removeChild(ta);
}
// Open the tool in a new tab
window.open(url, "_blank", "noopener,noreferrer");
};
return ( return (
<ResultCard <ResultCard
@@ -100,13 +134,6 @@ export function VideoPromptsCard({
/> />
</div> </div>
<p className="text-sm italic text-fg-muted">
Best for:{" "}
<span className="not-italic text-fg">
{current.tool_recommendation}
</span>
</p>
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border"> <div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1"> <p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
Negative Negative
@@ -115,6 +142,41 @@ export function VideoPromptsCard({
{NEGATIVE_VIDEO_PROMPT} {NEGATIVE_VIDEO_PROMPT}
</p> </p>
</div> </div>
{current.tool_recommendation && (
<div className="pt-1 text-xs font-medium text-fg">
Recommended AI: <span className="font-semibold text-accent-primary">{current.tool_recommendation}</span>
</div>
)}
{links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => {
const isRecommended = Boolean(
current.tool_recommendation &&
current.tool_recommendation.toLowerCase().includes(link.name.toLowerCase())
);
return (
<button
key={i}
type="button"
onClick={() => handleToolClick(link.url)}
className={
isRecommended
? "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary text-white shadow-lg shadow-accent-primary/30 transition-all duration-150 border border-accent-primary hover:bg-accent-primary/90"
: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-bg-card text-fg-muted hover:text-fg hover:bg-bg-hover transition-all duration-150 border border-border hover:border-accent-primary/40"
}
title={isRecommended ? `Recommended! Copy prompt and open ${link.name}` : `Copy prompt and open ${link.name}`}
>
{isRecommended && <FontAwesomeIcon icon={faStar} className="w-3 h-3 text-yellow-300" />}
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className={isRecommended ? "w-3 h-3 opacity-90" : "w-3 h-3 opacity-60"} />
</button>
);
})}
</div>
)}
</div> </div>
) : ( ) : (
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p> <p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
+9 -2
View File
@@ -35,9 +35,12 @@
html, body, #root { html, body, #root {
height: 100%; height: 100%;
overflow: hidden;
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, sans-serif;
cursor: url('/cursor-default.svg') 4 2, auto; cursor: url('/cursor-default.svg') 4 2, auto !important;
}
label {
cursor: url('/cursor-default.svg') 4 2, auto !important;
} }
a, button, [role="button"], select, .cursor-pointer { a, button, [role="button"], select, .cursor-pointer {
@@ -51,6 +54,7 @@
body { body {
@apply bg-bg text-fg antialiased transition-colors duration-300; @apply bg-bg text-fg antialiased transition-colors duration-300;
margin: 0; margin: 0;
overflow-x: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@@ -188,18 +192,21 @@
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw; position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-1 18s ease-in-out infinite; animation: blob-drift-1 18s ease-in-out infinite;
} }
.liquid-blob-2 { .liquid-blob-2 {
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw; position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-2 22s ease-in-out infinite; animation: blob-drift-2 22s ease-in-out infinite;
} }
.liquid-blob-3 { .liquid-blob-3 {
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw; position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none; opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-3 26s ease-in-out infinite; animation: blob-drift-3 26s ease-in-out infinite;
} }
+12
View File
@@ -71,6 +71,18 @@ export async function getServerStatus(
return (await resp.json()) as ServerStatus; return (await resp.json()) as ServerStatus;
} }
export interface ConfigResponse {
videoAiLinks: import("./types").VideoAiLink[];
}
export async function getConfig(signal?: AbortSignal): Promise<ConfigResponse> {
const resp = await fetch(`${API_BASE}/api/config`, { signal });
if (!resp.ok) {
throw new Error(`Server config check failed (${resp.status} ${resp.statusText})`);
}
return (await resp.json()) as ConfigResponse;
}
export interface GenerateOptions { export interface GenerateOptions {
signal?: AbortSignal; signal?: AbortSignal;
} }
+6
View File
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
video_prompts: "video prompts", video_prompts: "video prompts",
youtube_description: "description", youtube_description: "description",
}; };
export interface VideoAiLink {
name: string;
url: string;
}
+71
View File
@@ -0,0 +1,71 @@
import { useState, useEffect, useCallback } from "react";
export type SystemPrompts = {
all: string;
partial: string;
style_normal: string;
style_crazy: string;
lyrics_partial: string;
};
export function useSystemPrompts() {
const [prompts, setPrompts] = useState<SystemPrompts | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPrompts = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts");
if (!res.ok) throw new Error("Failed to fetch system prompts");
const data = await res.json();
setPrompts(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}, []);
const savePrompts = async (newPrompts: SystemPrompts) => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPrompts),
});
if (!res.ok) throw new Error("Failed to save system prompts");
setPrompts(newPrompts);
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
return false;
} finally {
setLoading(false);
}
};
const resetPrompts = async () => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts", { method: "DELETE" });
if (!res.ok) throw new Error("Failed to reset system prompts");
await fetchPrompts();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
return false;
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPrompts();
}, [fetchPrompts]);
return { prompts, loading, error, savePrompts, resetPrompts };
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { getConfig } from "./llm";
import type { VideoAiLink } from "./types";
const LOCAL_STORAGE_KEY = "melodymuse-video-links";
export function useVideoAiLinks() {
const [links, setLinks] = useState<VideoAiLink[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
async function fetchLinks() {
try {
const local = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (local) {
const parsed = JSON.parse(local);
if (Array.isArray(parsed)) {
if (active) {
setLinks(parsed);
setLoading(false);
}
return;
}
}
// Fetch from backend
const res = await getConfig();
if (active) {
setLinks(res.videoAiLinks || []);
setLoading(false);
}
} catch (err) {
if (active) {
setLoading(false);
}
}
}
fetchLinks();
return () => { active = false; };
}, []);
const saveLinks = (newLinks: VideoAiLink[]) => {
setLinks(newLinks);
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newLinks));
};
const clearLocalLinks = () => {
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
// Refresh to fetch defaults again
setLoading(true);
getConfig().then(res => {
setLinks(res.videoAiLinks || []);
setLoading(false);
}).catch(() => setLoading(false));
};
return { links, loading, saveLinks, clearLocalLinks, hasLocal: window.localStorage.getItem(LOCAL_STORAGE_KEY) !== null };
}
+48 -23
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faLightbulb, faMoon, faHistory, faCog } from "@fortawesome/free-solid-svg-icons"; import { faLightbulb, faMoon, faHistory, faCog, faRobot } from "@fortawesome/free-solid-svg-icons";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { InputPanel } from "../components/InputPanel"; import { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel"; import type { InputValues } from "../components/InputPanel";
@@ -26,7 +26,6 @@ import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme"; import { useTheme } from "../lib/theme";
const DRAFT_KEY = "melodymuse-draft"; const DRAFT_KEY = "melodymuse-draft";
const DRAFT_DEBOUNCE_MS = 400;
const DEFAULT_INPUT: InputValues = { const DEFAULT_INPUT: InputValues = {
idea: "", idea: "",
@@ -103,9 +102,8 @@ export function HomePage() {
const { t, lang, setLang } = useI18n(); const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [input, setInput] = useState<InputValues>( const inputRef = useRef<InputValues>(loadDraft() ?? DEFAULT_INPUT);
() => loadDraft() ?? DEFAULT_INPUT, const [forcedUpdate, setForcedUpdate] = useState<{ count: number; values: InputValues }>();
);
const [assets, setAssets] = useState<SongAssets | null>(null); const [assets, setAssets] = useState<SongAssets | null>(null);
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null); const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -119,14 +117,14 @@ export function HomePage() {
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating); const anyRegenerating = isAnyBusy(loading, regenerating);
// Save draft // Save draft periodically
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => { const id = window.setInterval(() => {
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); } try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(inputRef.current)); }
catch { /* ignore */ } catch { /* ignore */ }
}, DRAFT_DEBOUNCE_MS); }, 1000);
return () => window.clearTimeout(id); return () => window.clearInterval(id);
}, [input]); }, []);
// Server health check // Server health check
useEffect(() => { useEffect(() => {
@@ -168,7 +166,9 @@ export function HomePage() {
}, [anyRegenerating, cancel]); }, [anyRegenerating, cancel]);
const buildRequest = useCallback( const buildRequest = useCallback(
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({ (section: string, extra: Record<string, unknown> = {}): GenerateCall => {
const input = inputRef.current;
return {
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}), ...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
@@ -176,8 +176,9 @@ export function HomePage() {
vocals: input.vocals, vocals: input.vocals,
section, section,
...extra, ...extra,
} as unknown as GenerateCall), } as unknown as GenerateCall;
[input], },
[],
); );
const runGeneration = useCallback( const runGeneration = useCallback(
@@ -212,6 +213,7 @@ export function HomePage() {
); );
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setAssets(null); setAssets(null);
setOriginalAssets(null); setOriginalAssets(null);
@@ -228,9 +230,10 @@ export function HomePage() {
setLoading, setLoading,
t.assetsGenerated, t.assetsGenerated,
); );
}, [input, buildRequest, runGeneration, t]); }, [buildRequest, runGeneration, t]);
const handleRegenerateAll = useCallback(async () => { const handleRegenerateAll = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setAssets(null); setAssets(null);
setOriginalAssets(null); setOriginalAssets(null);
@@ -250,10 +253,11 @@ export function HomePage() {
}, },
t.regeneratedAll, t.regeneratedAll,
); );
}, [input, buildRequest, runGeneration, t]); }, [buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback( const handleRegenerateSection = useCallback(
async (section: SectionKey, selectedText?: string) => { async (section: SectionKey, selectedText?: string) => {
const input = inputRef.current;
if (!input.idea.trim() || !assets) return; if (!input.idea.trim() || !assets) return;
const isPartialLyrics = section === "lyrics" && !!selectedText; const isPartialLyrics = section === "lyrics" && !!selectedText;
@@ -269,8 +273,18 @@ export function HomePage() {
let merged: SongAssets; let merged: SongAssets;
if (isPartialLyrics) { if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics // Replace only the selected text in the existing lyrics
const newPartial = (result as any).lyrics || (result as any).text || String(result); let newPartial = (result as any).lyrics || (result as any).text || String(result);
const replaced = assets.lyrics.replace(selectedText, newPartial); // Cleanup quotes or markdown if the LLM leaked them
newPartial = newPartial.replace(/^```[a-z]*\n/gi, '').replace(/\n```$/g, '').trim();
if (newPartial.startsWith('"') && newPartial.endsWith('"')) {
newPartial = newPartial.slice(1, -1).trim();
}
// Normalize newlines to prevent replace() from failing due to \r\n vs \n
const normalizedAssetsLyrics = assets.lyrics.replace(/\r\n/g, '\n');
const normalizedSelected = selectedText.replace(/\r\n/g, '\n');
const replaced = normalizedAssetsLyrics.replace(normalizedSelected, newPartial);
merged = { ...assets, lyrics: replaced }; merged = { ...assets, lyrics: replaced };
} else { } else {
merged = { ...assets, ...result }; merged = { ...assets, ...result };
@@ -289,7 +303,7 @@ export function HomePage() {
`${t.regeneratedSection} ${SECTION_LABELS[section]}`, `${t.regeneratedSection} ${SECTION_LABELS[section]}`,
); );
}, },
[input, assets, buildRequest, runGeneration, t], [assets, buildRequest, runGeneration, t],
); );
// Execute chained regeneration // Execute chained regeneration
@@ -335,7 +349,8 @@ export function HomePage() {
const handleLoadHistory = useCallback( const handleLoadHistory = useCallback(
(entry: HistoryEntry) => { (entry: HistoryEntry) => {
if (anyRegenerating) cancel(); if (anyRegenerating) cancel();
setInput(entry.input); inputRef.current = entry.input;
setForcedUpdate((prev) => ({ count: (prev?.count ?? 0) + 1, values: entry.input }));
setAssets(entry.assets); setAssets(entry.assets);
setOriginalAssets(entry.assets); setOriginalAssets(entry.assets);
setSelectedTitleIndex(0); setSelectedTitleIndex(0);
@@ -371,11 +386,20 @@ export function HomePage() {
href="https://suno.com/create" href="https://suno.com/create"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-2 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-accent-primary/10 transition-all duration-150 font-bold text-[11px] tracking-wide uppercase" className="px-2 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-orange-500 hover:bg-orange-500/10 transition-all duration-150 font-bold text-[11px] tracking-wide uppercase"
title="Create on Suno" title="Create on Suno"
> >
Suno Suno
</a> </a>
<a
href="https://chat.orfel.de/c/new"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-blue-400 hover:bg-blue-400/10 transition-all duration-150"
title="Open Chat AI"
>
<FontAwesomeIcon icon={faRobot} className="w-4 h-4" />
</a>
<a <a
href="https://www.youtube.com/@AIWentNonsense" href="https://www.youtube.com/@AIWentNonsense"
target="_blank" target="_blank"
@@ -467,8 +491,9 @@ export function HomePage() {
<div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col"> <div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0"> <div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0">
<InputPanel <InputPanel
values={input} initialValues={inputRef.current}
onChange={setInput} forcedUpdate={forcedUpdate}
onChange={(next) => { inputRef.current = next; }}
onSubmit={handleGenerate} onSubmit={handleGenerate}
loading={loading} loading={loading}
disabled={anyRegenerating} disabled={anyRegenerating}
+220 -2
View File
@@ -9,11 +9,16 @@ import {
faPlug, faPlug,
faTrashAlt, faTrashAlt,
faServer, faServer,
faVideo,
faTerminal,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast"; import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm"; import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history"; import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n"; import { useI18n } from "../lib/i18n";
import { useVideoAiLinks } from "../lib/useVideoAiLinks";
import { useSystemPrompts, type SystemPrompts } from "../lib/useSystemPrompts";
import type { VideoAiLink } from "../lib/types";
const DRAFT_KEY = "melodymuse-draft"; const DRAFT_KEY = "melodymuse-draft";
@@ -41,6 +46,25 @@ export function SettingsPage() {
hasDraft: false, hasDraft: false,
}); });
const { links, loading: linksLoading, saveLinks, clearLocalLinks, hasLocal } = useVideoAiLinks();
const [editingLinks, setEditingLinks] = useState<VideoAiLink[] | null>(null);
const { prompts, loading: promptsLoading, savePrompts, resetPrompts } = useSystemPrompts();
const [editingPrompts, setEditingPrompts] = useState<SystemPrompts | null>(null);
const [activePromptTab, setActivePromptTab] = useState<keyof SystemPrompts>("all");
useEffect(() => {
if (!linksLoading && editingLinks === null) {
setEditingLinks(links);
}
}, [links, linksLoading, editingLinks]);
useEffect(() => {
if (!promptsLoading && editingPrompts === null && prompts) {
setEditingPrompts(prompts);
}
}, [prompts, promptsLoading, editingPrompts]);
const check = async (signal?: AbortSignal) => { const check = async (signal?: AbortSignal) => {
setChecking(true); setChecking(true);
setStatusError(null); setStatusError(null);
@@ -76,10 +100,70 @@ export function SettingsPage() {
toast.info(t.clearDraft); toast.info(t.clearDraft);
}; };
const handleLinkChange = (index: number, field: keyof VideoAiLink, value: string) => {
if (!editingLinks) return;
const next = [...editingLinks];
next[index] = { ...next[index], [field]: value };
setEditingLinks(next);
};
const handleAddLink = () => {
if (!editingLinks) return;
setEditingLinks([...editingLinks, { name: "", url: "" }]);
};
const handleRemoveLink = (index: number) => {
if (!editingLinks) return;
const next = [...editingLinks];
next.splice(index, 1);
setEditingLinks(next);
};
const handleSaveLinks = () => {
if (!editingLinks) return;
const validLinks = editingLinks.filter(l => l.name.trim() && l.url.trim());
saveLinks(validLinks);
setEditingLinks(validLinks);
toast.success("Video AI links saved to browser storage");
};
const handleResetLinks = () => {
if (!window.confirm("Restore default Video AI links from the server?")) return;
clearLocalLinks();
setEditingLinks(null); // will re-sync with hook
toast.info("Restored default links");
};
const handlePromptChange = (val: string) => {
if (!editingPrompts) return;
setEditingPrompts({ ...editingPrompts, [activePromptTab]: val });
};
const handleSavePrompts = async () => {
if (!editingPrompts) return;
const ok = await savePrompts(editingPrompts);
if (ok) {
toast.success("System Prompts saved to server");
} else {
toast.error("Failed to save prompts");
}
};
const handleResetPrompts = async () => {
if (!window.confirm("Restore default System Prompts? This will overwrite your changes.")) return;
const ok = await resetPrompts();
if (ok) {
setEditingPrompts(null); // Will re-sync with hook
toast.info("Restored default system prompts");
} else {
toast.error("Failed to restore prompts");
}
};
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border"> <header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center gap-2"> <div className="max-w-3xl mx-auto px-4 h-14 flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => navigate("/")} onClick={() => navigate("/")}
@@ -92,7 +176,7 @@ export function SettingsPage() {
</div> </div>
</header> </header>
<main className="max-w-md mx-auto px-4 mt-16"> <main className="max-w-3xl mx-auto px-4 mt-16">
<div className="card p-6 space-y-6"> <div className="card p-6 space-y-6">
<section> <section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2"> <h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
@@ -177,6 +261,140 @@ export function SettingsPage() {
</div> </div>
</section> </section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faVideo} className="w-4 h-4 text-accent-secondary" />
Video AI Links
</span>
{hasLocal && (
<button
type="button"
onClick={handleResetLinks}
className="text-[10px] uppercase font-bold text-fg-muted hover:text-fg transition-colors"
>
Reset to Server Defaults
</button>
)}
</h2>
<p className="text-xs text-fg-muted mb-4">
Configure the quick links shown at the bottom of the Video Prompts card.
{hasLocal ? " Currently using your local browser overrides." : " Currently using server defaults."}
</p>
{linksLoading || editingLinks === null ? (
<div className="flex items-center justify-center py-4">
<FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-5 h-5" />
</div>
) : (
<div className="space-y-3">
{editingLinks.map((link, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
className="input py-1.5 text-xs flex-1"
placeholder="Name (e.g. Kling AI)"
value={link.name}
onChange={e => handleLinkChange(i, "name", e.target.value)}
/>
<input
type="url"
className="input py-1.5 text-xs flex-[2]"
placeholder="https://..."
value={link.url}
onChange={e => handleLinkChange(i, "url", e.target.value)}
/>
<button
type="button"
onClick={() => handleRemoveLink(i)}
className="p-1.5 rounded-lg text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Remove"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleAddLink}
className="btn-secondary flex-1"
>
+ Add Link
</button>
<button
type="button"
onClick={handleSaveLinks}
className="btn-primary flex-1 py-1 text-sm font-semibold"
>
Save Links
</button>
</div>
</div>
)}
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faTerminal} className="w-4 h-4 text-accent-primary" />
System Prompts
</span>
<button
type="button"
onClick={handleResetPrompts}
className="text-[10px] uppercase font-bold text-fg-muted hover:text-fg transition-colors"
>
Restore Defaults
</button>
</h2>
<p className="text-xs text-fg-muted mb-4">
Edit the prompts used by the LLM. Use <code className="font-mono text-accent-secondary">{"{{VIDEO_TOOLS}}"}</code> as a placeholder for where the Video AI links should be injected in the <code className="font-mono">all</code> and <code className="font-mono">partial</code> prompts.
</p>
{promptsLoading || editingPrompts === null ? (
<div className="flex items-center justify-center py-4">
<FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-5 h-5" />
</div>
) : (
<div className="space-y-3">
<select
value={activePromptTab as string}
onChange={(e) => setActivePromptTab(e.target.value as keyof SystemPrompts)}
className="input py-1.5 text-xs w-full"
>
<option value="all">Full Generation (all)</option>
<option value="partial">Partial Generation (partial)</option>
<option value="style_normal">Style: Normal (style_normal)</option>
<option value="style_crazy">Style: Crazy (style_crazy)</option>
<option value="lyrics_partial">Lyrics Rewrite (lyrics_partial)</option>
</select>
<textarea
className="input w-full min-h-[500px] text-xs font-mono p-3 leading-relaxed"
value={editingPrompts[activePromptTab]}
onChange={(e) => handlePromptChange(e.target.value)}
spellCheck={false}
/>
<div className="flex justify-end pt-1">
<button
type="button"
onClick={handleSavePrompts}
className="btn-primary py-1 text-sm font-semibold px-6"
>
Save Prompts
</button>
</div>
</div>
)}
</section>
<div className="text-center pt-2"> <div className="text-center pt-2">
<Link <Link
to="/" to="/"
BIN
View File
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: 'You are a Suno style-prompt generator.' },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);
+46
View File
@@ -0,0 +1,46 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: `You are a Suno style-prompt generator with permission to break conventions.
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.
- Maximum 25 words.
- Output ONLY the style description line — no quotes, no commentary, no prefix,
no trailing punctuation.
Examples of the expected output (do NOT copy these verbatim):
- gregorian chant trap, deep male choir, sub 808s, 140 BPM, dark reverb
- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM` },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);