Compare commits

..
4 Commits
8 changed files with 423 additions and 41 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
# the browser to talk to a server on a different origin.
# 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"
+92 -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}`);
@@ -349,6 +364,23 @@ 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 idx = part.indexOf('=');
if (idx > 0) {
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
}
return null;
}).filter(link => link !== null);
}
json(res, { videoAiLinks });
}
async function handleStyleRandom(req, res) {
let body;
try { body = await readJsonBody(req); }
@@ -466,6 +498,10 @@ async function handleGenerate(req, res) {
signal,
);
if (body.section === 'lyrics_partial') {
return json(res, { lyrics: content });
}
const parsed = extractJson(content);
if (body.section === 'all') {
@@ -491,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
// ──────────────────────────────────────────────────────────────────────────────
@@ -508,6 +583,21 @@ const server = createServer(async (req, res) => {
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
return handleHealth(req, res);
}
if (req.method === 'GET' && req.url.split('?')[0] === '/api/config') {
return handleConfig(req, res);
}
// System Prompts endpoints
if (req.method === 'GET' && req.url.split('?')[0] === '/api/system-prompts') {
return handleGetSystemPrompts(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/system-prompts') {
return handlePostSystemPrompts(req, res);
}
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/system-prompts') {
return handleDeleteSystemPrompts(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
return handleGenerate(req, res);
}
+71 -38
View File
@@ -1,7 +1,8 @@
// System + user prompt construction for the LLM. Server-side only — the
// 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
object. Output ONLY valid JSON — no markdown, no backticks, no preamble.
@@ -49,10 +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): Runway Gen-3 Alpha, Kling AI,
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.
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.
@@ -80,11 +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.
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.
@@ -101,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.
@@ -123,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.
@@ -135,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 SYSTEM_PROMPT_ALL;
return SYSTEM_PROMPT_PARTIAL;
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) {
@@ -155,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');
}
+20
View File
@@ -9,6 +9,8 @@ import {
type VideoPrompt,
type VideoPromptType,
} from "../../lib/types";
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
interface VideoPromptsCardProps {
prompts: VideoPrompt[];
@@ -34,6 +36,7 @@ export function VideoPromptsCard({
const dirty = current?.prompt !== originalCurrent?.prompt;
const ref = useAutoHeight(current?.prompt ?? "", 120);
const { links } = useVideoAiLinks();
return (
<ResultCard
@@ -115,6 +118,23 @@ export function VideoPromptsCard({
{NEGATIVE_VIDEO_PROMPT}
</p>
</div>
{links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary/10 text-accent-primary hover:bg-accent-primary hover:text-white transition-all duration-150 border border-accent-primary/20 hover:border-accent-primary"
>
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className="w-3 h-3" />
</a>
))}
</div>
)}
</div>
) : (
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
+1 -1
View File
@@ -35,7 +35,6 @@
html, body, #root {
height: 100%;
overflow: hidden;
font-family: 'Inter', system-ui, sans-serif;
cursor: url('/cursor-default.svg') 4 2, auto;
}
@@ -51,6 +50,7 @@
body {
@apply bg-bg text-fg antialiased transition-colors duration-300;
margin: 0;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+12
View File
@@ -71,6 +71,18 @@ export async function getServerStatus(
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 {
signal?: AbortSignal;
}
+6
View File
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
video_prompts: "video prompts",
youtube_description: "description",
};
export interface VideoAiLink {
name: string;
url: string;
}
+218
View File
@@ -9,11 +9,16 @@ import {
faPlug,
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";
@@ -41,6 +46,25 @@ export function SettingsPage() {
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) => {
setChecking(true);
setStatusError(null);
@@ -76,6 +100,66 @@ export function SettingsPage() {
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 (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
@@ -177,6 +261,140 @@ export function SettingsPage() {
</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={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}
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="/"