diff --git a/server.mjs b/server.mjs index 2c9120d..98efad5 100644 --- a/server.mjs +++ b/server.mjs @@ -26,6 +26,8 @@ import { buildStylePrompt, buildSystemPrompt, buildUserMessage, + setActivePrompts, + DEFAULT_PROMPTS, } from './server/prompts.mjs'; // ────────────────────────────────────────────────────────────────────────────── @@ -38,6 +40,7 @@ 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 = { @@ -58,8 +61,20 @@ if (!LLM.endpoint || !LLM.apiKey) { // Ensure data directory exists await mkdir(DATA_DIR, { recursive: true }); -console.log(`[startup] LLM endpoint: ${LLM.endpoint}`); -console.log(`[startup] LLM model: ${LLM.model}`); +// 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}`); @@ -512,6 +527,45 @@ async function handleGenerate(req, res) { 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 // ────────────────────────────────────────────────────────────────────────────── @@ -532,6 +586,18 @@ const server = createServer(async (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); } diff --git a/server/prompts.mjs b/server/prompts.mjs index 89afb89..18113fc 100644 --- a/server/prompts.mjs +++ b/server/prompts.mjs @@ -1,20 +1,8 @@ // System + user prompt construction for the LLM. Server-side only — the // browser never sees these prompts. -export function getSystemPromptAll() { - let tools = 'Runway Gen-3 Alpha, Kling AI, Luma Dream Machine, Pika 2.0, 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 `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 object. Output ONLY valid JSON — no markdown, no backticks, no preamble. @@ -62,23 +50,8 @@ REQUIRED JSON STRUCTURE: } Keep each video_prompts[].prompt under 900 characters (excluding the Negative line). -Recommended video tools (choose the best fit per prompt): ${tools}. Never recommend Sora.`; -} - -export function getSystemPromptPartial() { - let tools = 'Runway Gen-3 Alpha, Kling AI, Luma Dream Machine, Pika 2.0, 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 `You are a music production assistant specializing in Suno AI song creation. +Recommended video tools (choose the best fit per prompt): {{VIDEO_TOOLS}}. Never recommend Sora.`, + 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 valid JSON — no markdown, no backticks, no preamble. @@ -106,12 +79,10 @@ QUALITY CONSTRAINTS: - style: comma-separated, max 120 words, include tempo + BPM range, never name an artist, no "sounds like" phrases. - video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line - inside each prompt. Keep prompts under 900 characters. Recommended tools: ${tools}. Never recommend Sora. + inside each prompt. Keep prompts under 900 characters. Recommended tools: {{VIDEO_TOOLS}}. Never recommend Sora. - youtube_description: follow the hook → description → divider → lyrics → - divider → CTA → hashtags structure.`; -} - -export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator. + divider → CTA → hashtags structure.`, + style_normal: `You are a Suno style-prompt generator. Output ONE short Suno style description. @@ -128,9 +99,8 @@ Examples of the expected output (do NOT copy these verbatim): - dark synthwave, analog pads, driving bassline, 110 BPM, male vocals - indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM - trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals -- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`; - -export const SYSTEM_PROMPT_STYLE_CRAZY = `You are a Suno style-prompt generator with permission to break conventions. +- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`, + style_crazy: `You are a Suno style-prompt generator with permission to break conventions. Output ONE short Suno style description. @@ -150,9 +120,8 @@ Examples of the expected output (do NOT copy these verbatim): - 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`; - -export const SYSTEM_PROMPT_LYRICS_PARTIAL = `You are a music production assistant specializing in Suno AI song creation. +- 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. The user has existing lyrics and wants to regenerate a SPECIFIC selected passage only. @@ -162,12 +131,44 @@ Rules: - 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.`; +- 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) { - if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL; - if (req.section === 'all') return getSystemPromptAll(); - return getSystemPromptPartial(); + const toolsStr = getToolsString(); + + 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) { @@ -182,29 +183,34 @@ export function buildUserMessage(req) { req.context?.lyrics ?? '', '', 'Selected passage to rewrite:', - req.selected_text ?? '', - ].filter(l => l !== null).join('\n'); + req.passage ?? '', + ] + .filter((line) => line !== null) + .join('\n'); } - const lines = []; - lines.push(`Music idea: ${req.input}`); - lines.push(`Language: ${req.language}`); - if (req.mood) lines.push(`Mood: ${req.mood}`); - if (req.style_hint) lines.push(`Style hint (user-provided): ${req.style_hint}`); - lines.push(`Vocals: ${req.vocals}`); - lines.push(`Section to generate: ${req.section}`); + const lines = [ + `Music idea: ${req.input}`, + `Language: ${req.language}`, + req.mood ? `Mood: ${req.mood}` : '', + req.vocals ? `Vocals: ${req.vocals}` : '', + ]; - if (req.context && Object.keys(req.context).length > 0) { + if (req.section !== 'all' && req.context) { lines.push(''); - lines.push('Context — the current values of the other sections of this song:'); - lines.push(JSON.stringify(req.context, null, 2)); + lines.push('CURRENT STATE OF OTHER SECTIONS:'); + 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'); -} - -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()}"`; + return lines.filter((line) => line !== null).join('\n'); } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index de40471..c751ca2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -10,12 +10,14 @@ import { faTrashAlt, faServer, faVideo, + faTerminal, } from "@fortawesome/free-solid-svg-icons"; import { useToast } from "../lib/toast"; import { getServerStatus, type ServerStatus } from "../lib/llm"; import { clearHistory } from "../lib/history"; 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"; @@ -47,12 +49,22 @@ export function SettingsPage() { const { links, loading: linksLoading, saveLinks, clearLocalLinks, hasLocal } = useVideoAiLinks(); const [editingLinks, setEditingLinks] = useState(null); + const { prompts, loading: promptsLoading, savePrompts, resetPrompts } = useSystemPrompts(); + const [editingPrompts, setEditingPrompts] = useState(null); + const [activePromptTab, setActivePromptTab] = useState("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) => { setChecking(true); setStatusError(null); @@ -122,6 +134,32 @@ export function SettingsPage() { 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 (
@@ -299,6 +337,64 @@ export function SettingsPage() { )} +
+ +
+

+ + + System Prompts + + +

+

+ Edit the prompts used by the LLM. Use {"{{VIDEO_TOOLS}}"} as a placeholder for where the Video AI links should be injected in the all and partial prompts. +

+ + {promptsLoading || editingPrompts === null ? ( +
+ +
+ ) : ( +
+ + +