feat: add editable system prompts from web UI

This commit is contained in:
2026-06-04 11:31:56 +02:00
parent ecd75a7fb5
commit f4cb318175
3 changed files with 235 additions and 67 deletions
+68 -2
View File
@@ -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);
}
+71 -65
View File
@@ -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');
}
+96
View File
@@ -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<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) => {
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 (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
@@ -299,6 +337,64 @@ export function SettingsPage() {
)}
</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}
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-[300px] 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">
<Link
to="/"