Move LLM key to the server, add random-style buttons

Architectural change

The browser no longer talks to the LLM directly. A new Node server
(server.mjs) sits in the middle:

  [Browser] → [Node :3000] → [LLM provider]
    (no key)    (key in .env)

server.mjs is the production runtime: it serves the built SPA out of
dist/ and exposes three JSON endpoints that proxy to the LLM with
credentials held in process.env. The browser-side llm.ts is now a thin
fetch wrapper.

- server.mjs: single-file Node server, no production deps
- server/prompts.mjs: system + user prompt construction (was client-side)
- src/lib/prompts.ts removed (moved server-side)
- src/lib/llm.ts rewritten — no more direct LLM calls, no more
  JSON extraction, no more validation; just fetch the proxy
- src/lib/types.ts: drop SaveConfigPayload/TestConnectionResult, add
  style_hint and ServerStatus
- vite.config.ts: proxy /api/* → localhost:3000 in dev
- .env.example: LLM_* and PORT/CORS_ORIGIN instead of Supabase values

New feature: random style buttons

The Options → Music style field now has two AI buttons that fill it
with a fresh Suno style description:
- 'Surprise me' → coherent, production-ready style (max 25 words)
- 'Go crazy'   → deliberately clashing genre mashup (max 25 words)

The buttons hit a dedicated /api/style/random endpoint on the server
that uses a small, focused system prompt. Each click overwrites the
field. Both buttons show a spinner and disable while a request is
in flight. Errors surface as toasts. AbortController is used so a
fast second click cancels the first.

When the Music style field is non-empty at generation time, its value
is sent to the model as style_hint and used as the basis for the full
120-word style field (per the updated system prompt).

Other UX

- Settings page is now a server-status page: green/red indicator,
  model + endpoint, re-check button. The API key is no longer
  configurable in the browser (it never was reachable anyway — now
  the UI is honest about that).
- Home page header shows a small 'Server offline' warning when the
  server is unreachable.
- Settings has a Local data section: list what's in localStorage
  with one-click clear-history and clear-all buttons (with confirm).
- Esc cancels any in-flight generation.
- ZIP filename falls back to 'song' if the title sanitizes to empty.

Deployment

deploy/ holds reference files (Dockerfile, docker-compose example,
Caddy fragment, generate-env.sh, README) for adding the service to a
Jannik-Cloud-style stack. The repo is intentionally not wired into
the Jannik-Cloud repo; copy the four files when ready.
This commit is contained in:
2026-06-03 08:01:21 +02:00
parent b945417773
commit d8b25ec6ab
17 changed files with 1597 additions and 759 deletions
-32
View File
@@ -1,32 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
interface ConfigBannerProps {
message: string;
linkTo: string;
linkLabel: string;
}
export function ConfigBanner({ message, linkTo, linkLabel }: ConfigBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
<p className="flex-1">
{message}{' '}
<Link to={linkTo} className="font-semibold underline underline-offset-2 hover:text-amber-100">
{linkLabel}
</Link>
</p>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-amber-200/70 hover:text-amber-100 transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+103 -5
View File
@@ -7,10 +7,14 @@ import {
Settings as SettingsIcon,
Sparkles,
Shuffle,
Flame,
Wand2,
} from "lucide-react";
import { EqualizerIcon } from "./EqualizerIcon";
import type { InputValues, Language, Vocals } from "../lib/types";
import { formatElapsed, useElapsed } from "../lib/useElapsed";
import { randomStyle } from "../lib/llm";
import { useToast } from "../lib/toast";
export type { InputValues };
@@ -68,7 +72,11 @@ export function InputPanel({
cancelButton,
}: InputPanelProps) {
const [optionsOpen, setOptionsOpen] = useState(false);
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(
null,
);
const elapsed = useElapsed(loading);
const toast = useToast();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
@@ -98,6 +106,25 @@ export function InputPanel({
});
};
const handleStyleRandom = async (mode: "normal" | "crazy") => {
if (styleLoading) return; // already running, don't fire two in parallel
const ctrl = new AbortController();
setStyleLoading(mode);
try {
const style = await randomStyle(mode, ctrl.signal);
onChange({ ...values, style_hint: style });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
toast.error(
err instanceof Error
? err.message
: `Could not generate a ${mode} style — please try again`,
);
} finally {
setStyleLoading((curr) => (curr === mode ? null : curr));
}
};
return (
<div className="relative">
{/* Animated gradient backdrop behind the header */}
@@ -144,10 +171,7 @@ export function InputPanel({
/>
<p className="mt-1 text-[11px] text-fg-muted">
<kbd className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
{navigator?.platform?.toLowerCase().includes("mac")
? "⌘"
: "Ctrl"}
+Enter
{isMac() ? "⌘" : "Ctrl"}+Enter
</kbd>{" "}
to generate
</p>
@@ -199,12 +223,20 @@ export function InputPanel({
)}
</div>
<StyleField
value={values.style_hint}
loading={styleLoading}
onChange={(v) => set("style_hint", v)}
onRandom={handleStyleRandom}
/>
<div>
<label
htmlFor="mood"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Mood
Mood{" "}
<span className="text-fg-muted/70 normal-case">(optional)</span>
</label>
<input
id="mood"
@@ -284,3 +316,69 @@ export function InputPanel({
</div>
);
}
interface StyleFieldProps {
value: string;
loading: null | "normal" | "crazy";
onChange: (v: string) => void;
onRandom: (mode: "normal" | "crazy") => void;
}
function StyleField({ value, loading, onChange, onRandom }: StyleFieldProps) {
return (
<div>
<label
htmlFor="style_hint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Music style{" "}
<span className="text-fg-muted/70 normal-case">(optional)</span>
</label>
<textarea
id="style_hint"
rows={2}
placeholder="e.g. dark synthwave, analog pads, 110 BPM"
value={value}
onChange={(e) => onChange(e.target.value)}
className="textarea text-sm"
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onRandom("normal")}
disabled={loading !== null}
className="btn-secondary text-xs py-2"
title="Generate a coherent Suno-friendly style description"
>
{loading === "normal" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Wand2 className="w-3.5 h-3.5" />
)}
Surprise me
</button>
<button
type="button"
onClick={() => onRandom("crazy")}
disabled={loading !== null}
className="btn-secondary text-xs py-2"
title="Generate an unusual genre mashup that still works in Suno"
>
{loading === "crazy" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Flame className="w-3.5 h-3.5" />
)}
Go crazy
</button>
</div>
</div>
);
}
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
const p = navigator.platform || "";
const ua = navigator.userAgent || "";
return /Mac|iPhone|iPad|iPod/.test(p) || /Mac OS X/.test(ua);
}
+71 -334
View File
@@ -1,284 +1,86 @@
// Direct browser → LLM client.
// Browser → server (proxy) → LLM client.
//
// The app runs as a standalone SPA: there is no backend. The user enters their
// provider endpoint, API key, and model name in /settings; those values are
// persisted in localStorage and used to call the provider's OpenAI-compatible
// /chat/completions route directly from the browser.
//
// SECURITY NOTE: because the API key lives in the browser, this app is intended
// for personal/local use. Do NOT deploy it to a public URL with a real key in
// the same browser's storage.
// The browser no longer talks to the LLM directly. It only ever talks to the
// bundled Node server, which holds the API key in its environment. This file
// stays tiny on purpose: it formats the request, forwards it, and unwraps the
// response.
import { buildSystemPrompt, buildUserMessage } from "./prompts";
import type {
ConfigDisplay,
GenerateRequest,
InputValues,
SongAssets,
TestConnectionResult,
} from "./types";
import type { GenerateRequest, SongAssets } from "./types";
const STORAGE_KEY = "melodymuse-config";
export const DEFAULT_MODEL = "MiniMax-M3";
// In production the server is same-origin, so leaving VITE_API_BASE_URL
// unset works. In dev, Vite's proxy (see vite.config.ts) makes it the same
// from the browser's perspective. Override here only if you deploy the SPA
// and the server to different origins.
const API_BASE =
(import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "";
export interface StoredConfig {
api_endpoint: string;
api_key: string;
model_name: string;
}
function defaultConfig(): StoredConfig {
return { api_endpoint: "", api_key: "", model_name: DEFAULT_MODEL };
}
export function loadConfig(): StoredConfig {
if (typeof window === "undefined") return defaultConfig();
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultConfig();
const parsed = JSON.parse(raw) as Partial<StoredConfig>;
return {
api_endpoint:
typeof parsed.api_endpoint === "string" ? parsed.api_endpoint : "",
api_key: typeof parsed.api_key === "string" ? parsed.api_key : "",
model_name:
typeof parsed.model_name === "string" && parsed.model_name.length > 0
? parsed.model_name
: DEFAULT_MODEL,
};
} catch {
return defaultConfig();
}
}
export function saveConfig(next: StoredConfig): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
export function isConfigured(cfg: StoredConfig): boolean {
return Boolean(cfg.api_endpoint && cfg.api_key && cfg.model_name);
}
// ──────────────────────────────────────────────────────────────────────────────
// Public settings API used by the UI
// ──────────────────────────────────────────────────────────────────────────────
export function getConfigDisplay(): ConfigDisplay {
const cfg = loadConfig();
return {
api_endpoint: cfg.api_endpoint,
model_name: cfg.model_name,
api_key_set: Boolean(cfg.api_key),
};
}
/**
* Save the configuration. If `api_key` is the empty string the previously
* stored key is preserved.
*/
export function setConfig(patch: Partial<StoredConfig>): ConfigDisplay {
const current = loadConfig();
const next: StoredConfig = {
api_endpoint:
typeof patch.api_endpoint === "string"
? patch.api_endpoint.trim()
: current.api_endpoint,
api_key:
typeof patch.api_key === "string" ? patch.api_key : current.api_key,
model_name:
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
? patch.model_name.trim()
: current.model_name,
};
// If the caller passed an empty api_key explicitly, keep the existing one.
if (typeof patch.api_key === "string" && patch.api_key.length === 0) {
next.api_key = current.api_key;
}
saveConfig(next);
return getConfigDisplay();
}
/**
* Persist *only* the fields the caller passed that are non-empty. Unlike
* `setConfig`, this overwrites the endpoint/model and replaces the API key if
* a new one is provided. Used by /settings's Test Connection path when the
* user is in the middle of editing and wants to try values before committing.
*/
export function previewConfig(patch: Partial<StoredConfig>): StoredConfig {
const current = loadConfig();
const candidate: StoredConfig = {
api_endpoint:
typeof patch.api_endpoint === "string" &&
patch.api_endpoint.trim().length > 0
? patch.api_endpoint.trim()
: current.api_endpoint,
api_key:
typeof patch.api_key === "string" && patch.api_key.length > 0
? patch.api_key
: current.api_key,
model_name:
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
? patch.model_name.trim()
: current.model_name,
};
return candidate;
}
// ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation
// ──────────────────────────────────────────────────────────────────────────────
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function extractJson(text: string): unknown {
const trimmed = text.trim();
// 1. Direct parse.
try {
return JSON.parse(trimmed);
} catch {
/* fall through */
}
// 2. First ```json ... ``` fenced block.
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) {
try {
return JSON.parse(fenced[1].trim());
} catch {
/* fall through */
}
}
// 3. First balanced { ... } region in the text.
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace > firstBrace) {
const slice = trimmed.slice(firstBrace, lastBrace + 1);
try {
return JSON.parse(slice);
} catch {
/* fall through */
}
}
throw new Error("Model response did not contain a parseable JSON object");
}
function validateShape(parsed: unknown): asserts parsed is Partial<SongAssets> {
if (!isObject(parsed)) throw new Error("Model response is not an object");
for (const k of [
"titles",
"lyrics",
"style",
"negative_style",
"youtube_description",
"video_prompts",
] as const) {
if (!(k in parsed)) continue;
const v = parsed[k];
if (k === "titles") {
if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
throw new Error('"titles" must be an array of strings');
}
} else if (k === "video_prompts") {
if (!Array.isArray(v))
throw new Error('"video_prompts" must be an array');
} else if (typeof v !== "string") {
throw new Error(`"${k}" must be a string`);
}
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Provider call
// ──────────────────────────────────────────────────────────────────────────────
interface ProviderMessage {
role: "system" | "user";
content: string;
}
interface ProviderRequest {
model: string;
max_tokens: number;
messages: ProviderMessage[];
[k: string]: unknown;
}
async function callProvider(
body: ProviderRequest,
cfg: StoredConfig,
async function postJson<T>(
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<string> {
const base = cfg.api_endpoint.replace(/\/+$/, "");
const url = `${base}/chat/completions`;
): Promise<T> {
let resp: Response;
try {
resp = await fetch(url, {
resp = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cfg.api_key}`,
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Re-throw so the caller can recognize a cancellation distinctly.
throw err;
}
// fetch() throws TypeError for network/CORS failures. Surface a helpful
// message — the user is running the app standalone, so CORS is the most
// likely culprit.
if (err instanceof DOMException && err.name === "AbortError") throw err;
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not reach ${url}. This is often a CORS restriction on the provider's endpoint — ` +
`check the provider's browser-access policy, or use a CORS proxy / browser extension. ` +
`(Details: ${detail})`,
`Could not reach the MelodyMuse server. ` +
`Is it running? (Tried: ${API_BASE || window.location.origin}${path}. ` +
`Details: ${detail})`,
);
}
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ""}`,
);
let payload: { error?: string } | null = null;
try {
payload = await resp.json();
} catch {
/* not JSON */
}
const msg =
payload?.error || `Server returned ${resp.status} ${resp.statusText}`;
throw new Error(msg);
}
let data: { choices?: Array<{ message?: { content?: unknown } }> };
try {
data = await resp.json();
} catch {
throw new Error("Provider returned a non-JSON response");
}
const content = data.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content.trim()) {
throw new Error("Provider response did not include a message");
}
return content;
return (await resp.json()) as T;
}
// ──────────────────────────────────────────────────────────────────────────────
// High-level operations
// ──────────────────────────────────────────────────────────────────────────────
export interface ServerStatus {
ok: boolean;
llm_configured: boolean;
model: string;
endpoint: string;
}
// `section: "all"` is guaranteed to return every key. Per-section regeneration
// only returns the keys the model chose to update. Model this with overloads so
// the client gets accurate typing.
type AllRequest = Omit<GenerateRequest, "section"> & { section: "all" };
type PartialRequest = Omit<GenerateRequest, "section"> & {
section: Exclude<GenerateRequest["section"], "all">;
};
export async function getServerStatus(
signal?: AbortSignal,
): Promise<ServerStatus> {
const resp = await fetch(`${API_BASE}/api/health`, { signal });
if (!resp.ok) {
throw new Error(
`Server health check failed (${resp.status} ${resp.statusText})`,
);
}
return (await resp.json()) as ServerStatus;
}
export interface GenerateOptions {
signal?: AbortSignal;
}
// Overloads mirror the section-specific shape of the server's response.
type AllRequest = GenerateRequest & { section: "all" };
type PartialRequest = GenerateRequest & {
section: Exclude<GenerateRequest["section"], "all">;
};
export async function generateSong(
req: AllRequest,
options?: GenerateOptions,
@@ -295,87 +97,22 @@ export async function generateSong(
req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>> {
const cfg = loadConfig();
if (!isConfigured(cfg)) {
throw new Error("API not configured. Please go to Settings.");
}
return postJson<Partial<SongAssets>>("/api/generate", req, options?.signal);
}
const content = await callProvider(
{
model: cfg.model_name,
max_tokens: 4000,
messages: [
{ role: "system", content: buildSystemPrompt(req) },
{ role: "user", content: buildUserMessage(req) },
],
},
cfg,
options?.signal,
export interface StyleRandomResponse {
style: string;
mode: "normal" | "crazy";
}
export async function randomStyle(
mode: "normal" | "crazy",
signal?: AbortSignal,
): Promise<string> {
const data = await postJson<StyleRandomResponse>(
"/api/style/random",
{ mode },
signal,
);
let parsed: unknown;
try {
parsed = extractJson(content);
} catch (err) {
throw new Error(
err instanceof Error ? err.message : "Model returned unparseable JSON",
);
}
try {
validateShape(parsed);
} catch (err) {
throw new Error(
err instanceof Error
? err.message
: "Model JSON did not match expected shape",
);
}
return parsed as Partial<SongAssets>;
return data.style;
}
/**
* Test the connection using the *currently saved* configuration.
*/
export async function testConnection(): Promise<TestConnectionResult> {
return testConnectionWithConfig(loadConfig());
}
/**
* Test the connection using an arbitrary (in-memory) configuration. Used by
* the Settings page to try out a candidate key/endpoint before persisting it.
*/
export async function testConnectionWithConfig(
cfg: StoredConfig,
): Promise<TestConnectionResult> {
if (!isConfigured(cfg)) {
return {
success: false,
message: "API not configured. Please go to Settings.",
};
}
try {
await callProvider(
{
model: cfg.model_name,
max_tokens: 5,
messages: [{ role: "user", content: "Say hello" }],
},
cfg,
);
return { success: true, message: "Connection successful" };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return { success: false, message: "Cancelled" };
}
return {
success: false,
message: err instanceof Error ? err.message : "Connection test failed",
};
}
}
// Re-export so consumers can import everything from one place.
export type { InputValues };
-103
View File
@@ -1,103 +0,0 @@
// System + user prompt construction for the LLM. Keeping it in its own module
// avoids a 200-line string literal in the llm module entry point.
import type { GenerateRequest } from './types';
export const SYSTEM_PROMPT_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.
LANGUAGE RULES (strict):
- "lyrics", "titles", and "youtube_description" → use the "language" field value
(default: English if not specified)
- "style", "negative_style", and all "video_prompts" → ALWAYS English, regardless
of the language field
REQUIRED JSON STRUCTURE:
{
"titles": [
"Title 1: atmospheric/poetic (max 4 words)",
"Title 2: direct/strong (max 4 words)",
"Title 3: abstract/intriguing (max 4 words)"
],
"lyrics": "Full lyrics with Suno section tags [Verse], [Chorus], [Bridge], [Outro], etc. Use AABB or ABAB rhyme scheme consistently. Lines must be rhythmically singable. CRITICAL: The lyrics must NOT reference the genre, instruments, or music production. No references to pianos, guitars, beats, orchestras, or any musical elements. Write about universal human themes: emotion, nature, love, time, memory, journey, longing.",
"style": "Comma-separated Suno style description, max 120 words. Include: tempo with BPM range, key instruments, production style, texture, energy level, mood, vocal style (or 'instrumental, no vocals'). NEVER name any artist, band, or use 'sounds like'.",
"negative_style": "Comma-separated list of genres, instruments, moods, and styles that should NOT appear.",
"youtube_description": "In the same language as the lyrics. Follow this exact structure:\\n\\n[One evocative hook sentence tied to the mood]\\n\\n[2-3 sentences describing the music, mood, and emotional experience]\\n\\n────────────────────────────\\n\\n[Full lyrics, copied exactly]\\n\\n────────────────────────────\\n\\n[Warm, genuine call to action: ask viewers to like, subscribe, and comment what the music makes them feel or imagine]\\n\\n#hashtag1 #hashtag2 ... (12-15 hashtags: mix of genre, mood, and tool tags like #SunoAI #AIMusic)",
"video_prompts": [
{
"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",
"tool_recommendation": "Tool name(s) + one-sentence reason why they suit this prompt"
},
{
"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",
"tool_recommendation": "Tool name(s) + one-sentence reason"
},
{
"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",
"tool_recommendation": "Tool name(s) + one-sentence reason"
}
]
}
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 PARTIAL_SYSTEM_PROMPT = `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.
LANGUAGE RULES (strict):
- When regenerating "lyrics", "titles", or "youtube_description" → use the
"language" field value (default: English if not specified).
- When regenerating "style", "negative_style", or "video_prompts" → ALWAYS
English, regardless of the language field.
You will be given:
- the user's original idea, language, mood, and vocals preference
- the current state of the other sections (so you can keep the tone, length,
and vocabulary consistent)
Output the requested section(s) using the same JSON shape as the full prompt
(titles array, lyrics string, style string, negative_style string,
youtube_description string, video_prompts array). Include only the keys that
are needed for the requested section; omit keys that belong to other sections
unless they are required for context.
QUALITY CONSTRAINTS:
- lyrics: Suno section tags [Verse], [Chorus], [Bridge], [Outro], etc. AABB or
ABAB rhyme. No references to instruments, genre, or music production.
- 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.
- youtube_description: follow the hook → description → divider → lyrics →
divider → CTA → hashtags structure.`;
export function buildSystemPrompt(req: GenerateRequest): string {
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return PARTIAL_SYSTEM_PROMPT;
}
export function buildUserMessage(req: GenerateRequest): string {
const lines: string[] = [];
lines.push(`Music idea: ${req.input}`);
lines.push(`Language: ${req.language}`);
if (req.mood) lines.push(`Mood: ${req.mood}`);
lines.push(`Vocals: ${req.vocals}`);
lines.push(`Section to generate: ${req.section}`);
if (req.context && Object.keys(req.context).length > 0) {
lines.push('');
lines.push('Context — the current values of the other sections of this song:');
lines.push(JSON.stringify(req.context, null, 2));
}
return lines.join('\n');
}
+6 -15
View File
@@ -1,5 +1,5 @@
// Domain types shared across the app. These mirror the JSON shape produced by
// the LLM module.
// Domain types shared across the app. The server returns the same shapes
// described here, so the client can trust the wire format at the type level.
export type Language =
| "English"
@@ -40,29 +40,20 @@ export interface SongAssets {
export interface GenerateRequest {
input: string;
language: string; // 'English' or free-text from "Other"
language: string;
mood?: string;
style_hint?: string;
vocals: Vocals;
section: GenerationSection;
context?: Partial<SongAssets>;
}
export interface ConfigDisplay {
api_endpoint: string;
model_name: string;
api_key_set: boolean;
}
export interface TestConnectionResult {
success: boolean;
message: string;
}
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
style_hint: string; // new — fed by the "Surprise me" / "Go crazy" buttons
mood: string; // optional secondary field
vocals: Vocals;
}
+74 -52
View File
@@ -7,12 +7,11 @@ import {
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { ConfigBanner } from "../components/ConfigBanner";
import { ThemeToggle } from "../components/ThemeToggle";
import { HistoryPanel } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { useToast } from "../lib/toast";
import { generateSong, getConfigDisplay } from "../lib/llm";
import { generateSong, getServerStatus } from "../lib/llm";
import { addToHistory, type HistoryEntry } from "../lib/history";
import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip";
import {
@@ -25,6 +24,7 @@ const DEFAULT_INPUT: InputValues = {
idea: "",
language: "English",
customLanguage: "",
style_hint: "",
mood: "",
vocals: "vocals",
};
@@ -35,7 +35,6 @@ function resolveLanguage(v: InputValues): string {
: v.language;
}
// `true` when any in-flight generation is running, false otherwise.
function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
if (loading) return true;
for (const v of Object.values(regenerating)) if (v) return true;
@@ -53,7 +52,7 @@ export function HomePage() {
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
const [serverOk, setServerOk] = useState<boolean | null>(null);
// AbortController for the *current* network request. Refs are used so the
// cancel handler always sees the latest value without re-binding.
@@ -61,11 +60,18 @@ export function HomePage() {
const anyRegenerating = isAnyBusy(loading, regenerating);
// Check localStorage on mount: if the API key isn't set, show a banner
// pointing the user at Settings.
// Check the server on mount. If unreachable, surface a quiet banner.
useEffect(() => {
const cfg = getConfigDisplay();
setApiKeySet(cfg.api_key_set);
const ctrl = new AbortController();
(async () => {
try {
const s = await getServerStatus(ctrl.signal);
setServerOk(Boolean(s.ok));
} catch {
setServerOk(false);
}
})();
return () => ctrl.abort();
}, []);
// Clean up any in-flight request on unmount.
@@ -83,11 +89,43 @@ export function HomePage() {
abortRef.current = null;
}, []);
// Esc cancels any in-flight generation.
useEffect(() => {
if (!anyRegenerating) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyRegenerating, cancel]);
// Build the request payload from the current form values. Kept as a
// function so all three call sites (full / regenerate all / per-section)
// stay in sync.
const buildRequest = useCallback(
(
section: GenerateCall["section"],
extra: Partial<GenerateCall> = {},
): GenerateCall => ({
input: input.idea.trim(),
language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
...(input.style_hint.trim()
? { style_hint: input.style_hint.trim() }
: {}),
vocals: input.vocals,
section,
...extra,
}),
[input],
);
const runGeneration = useCallback(
async (
buildRequest: (
signal: AbortSignal,
) => Promise<GenerateCall> | GenerateCall,
build: () => GenerateCall,
onSuccess: (result: Partial<SongAssets>) => void,
busySetter: (b: boolean) => void,
successMsg: string,
@@ -99,7 +137,7 @@ export function HomePage() {
busySetter(true);
try {
const req = await buildRequest(ctrl.signal);
const req = build();
const result = await generateSong(req, { signal: ctrl.signal });
onSuccess(result);
toast.success(successMsg);
@@ -124,36 +162,23 @@ export function HomePage() {
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all" as const,
}),
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
setOriginalAssets(next);
setSelectedTitleIndex(0);
// Persist to history. Don't `await` — the user shouldn't wait.
addToHistory(input, next);
},
setLoading,
"Song assets generated",
);
}, [input, runGeneration]);
}, [input, buildRequest, runGeneration]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all" as const,
}),
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
@@ -167,23 +192,16 @@ export function HomePage() {
},
"Regenerated all sections",
);
}, [input, runGeneration]);
}, [input, buildRequest, runGeneration]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
if (!input.idea.trim() || !assets) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section,
context: assets,
}),
() => buildRequest(section, { context: assets }),
(result) => {
// Merge partial into current assets, and update the original snapshot
// for the keys that the model returned. Any keys the user has
// Merge partial into current assets, and update the original
// snapshot for the keys that came back. Any keys the user has
// hand-edited that the model *didn't* return stay as-is.
const merged: SongAssets = { ...assets, ...result };
setAssets(merged);
@@ -193,14 +211,15 @@ export function HomePage() {
`Regenerated ${SECTION_LABELS[section]}`,
);
},
[input, assets, runGeneration],
[input, assets, buildRequest, runGeneration],
);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
const blob = await buildSongZip(assets, selectedTitle);
downloadBlob(blob, `${sanitizeFilename(selectedTitle)}.zip`);
const safe = sanitizeFilename(selectedTitle);
downloadBlob(blob, `${safe || "song"}.zip`);
} catch {
toast.error("Could not generate ZIP — please try again");
}
@@ -230,13 +249,14 @@ export function HomePage() {
// Restore a previous generation from history.
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
if (anyRegenerating) cancel();
setInput(entry.input);
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
},
[toast],
[anyRegenerating, cancel, toast],
);
return (
@@ -244,19 +264,21 @@ export function HomePage() {
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<span className="text-sm text-fg-muted">MelodyMuse</span>
<ThemeToggle />
<div className="flex items-center gap-3">
{serverOk === false && (
<span
className="text-xs text-rose-300 hidden sm:inline"
title="The MelodyMuse server is unreachable. Generation will fail."
>
Server offline
</span>
)}
<ThemeToggle />
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
{!apiKeySet && (
<ConfigBanner
message="No API key configured —"
linkTo="/settings"
linkLabel="Go to Settings →"
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
<InputPanel
@@ -264,6 +286,7 @@ export function HomePage() {
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
cancelButton={
<button
type="button"
@@ -315,5 +338,4 @@ function truncate(s: string, n: number): string {
return t.slice(0, n - 1) + "…";
}
// Internal alias so the `runGeneration` helper stays readable.
type GenerateCall = Parameters<typeof generateSong>[0];
+178 -211
View File
@@ -2,113 +2,105 @@ import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Eye,
EyeOff,
CheckCircle2,
AlertTriangle,
Loader2,
Save,
Plug,
Trash2,
Server,
} from "lucide-react";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import {
getConfigDisplay,
setConfig,
previewConfig,
testConnectionWithConfig,
saveConfig,
loadConfig,
DEFAULT_MODEL,
} from "../lib/llm";
import { getServerStatus, type ServerStatus } from "../lib/llm";
const DEFAULTS = {
api_endpoint: "",
api_key: "",
model_name: DEFAULT_MODEL,
};
const STORAGE_KEY = "melodymuse-config";
const HISTORY_KEY = "melodymuse-history";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary {
historyEntries: number;
hasTheme: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { historyEntries: 0, hasTheme: false };
}
let historyEntries = 0;
let hasTheme = false;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) historyEntries = parsed.length;
}
} catch {
/* ignore */
}
hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasTheme };
}
export function SettingsPage() {
const toast = useToast();
const navigate = useNavigate();
const [endpoint, setEndpoint] = useState(DEFAULTS.api_endpoint);
const [model, setModel] = useState(DEFAULTS.model_name);
const [apiKey, setApiKey] = useState(""); // never pre-populated from storage
const [apiKeySet, setApiKeySet] = useState(false);
const [showKey, setShowKey] = useState(false);
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasTheme: false,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const check = async (signal?: AbortSignal) => {
setChecking(true);
setStatusError(null);
try {
const s = await getServerStatus(signal);
setStatus(s);
} catch (err) {
setStatusError(err instanceof Error ? err.message : "Server unreachable");
setStatus(null);
} finally {
setChecking(false);
}
};
// Load current (non-secret) values on mount.
useEffect(() => {
const cfg = getConfigDisplay();
setEndpoint(cfg.api_endpoint);
setModel(cfg.model_name);
setApiKeySet(cfg.api_key_set);
setLoading(false);
const ctrl = new AbortController();
check(ctrl.signal);
setSummary(readLocalDataSummary());
return () => ctrl.abort();
}, []);
const onSave = (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
toast.success("Configuration saved");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
} finally {
setSaving(false);
}
};
// Test using *in-memory* form values — don't persist until the user clicks
// Save. An empty API key field still falls back to the previously saved key,
// matching the behaviour of Save.
const onTest = async () => {
setTesting(true);
const candidate = previewConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
const onClearHistory = () => {
if (summary.historyEntries === 0) return;
if (
!candidate.api_endpoint ||
!candidate.api_key ||
!candidate.model_name
) {
setTesting(false);
toast.error("❌ Fill in endpoint, key, and model before testing");
!window.confirm(
`Delete all ${summary.historyEntries} recent generation${
summary.historyEntries === 1 ? "" : "s"
} from this browser? This cannot be undone.`,
)
)
return;
}
const result = await testConnectionWithConfig(candidate);
if (result.success) {
toast.success(`${result.message}`);
} else {
toast.error(`${result.message}`);
}
setTesting(false);
window.localStorage.removeItem(HISTORY_KEY);
setSummary((s) => ({ ...s, historyEntries: 0 }));
toast.info("Recent generations cleared");
};
const onClearKey = () => {
if (!apiKeySet) return;
if (!window.confirm("Clear the saved API key from this browser?")) return;
const current = loadConfig();
saveConfig({ ...current, api_key: "" });
setApiKeySet(false);
setApiKey("");
toast.info("Saved API key cleared");
const onClearAll = () => {
if (
!window.confirm(
"Clear all MelodyMuse data from this browser? This will remove the recent generations and the theme preference. The API key on the server is NOT affected.",
)
)
return;
window.localStorage.removeItem(STORAGE_KEY);
window.localStorage.removeItem(HISTORY_KEY);
window.localStorage.removeItem(THEME_KEY);
setSummary({ historyEntries: 0, hasTheme: false });
toast.info("Local data cleared");
};
return (
@@ -123,150 +115,118 @@ export function SettingsPage() {
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-sm font-semibold text-fg">API Configuration</h1>
<h1 className="text-sm font-semibold text-fg">Settings</h1>
<ThemeToggle />
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<div className="card p-6">
{loading ? (
<div className="space-y-4 animate-pulse">
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<div>
<label
htmlFor="endpoint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Endpoint URL
</label>
<input
id="endpoint"
type="text"
className="input"
placeholder="https://api.minimax.chat/v1"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
/>
</div>
<div className="card p-6 space-y-6">
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
<Server className="w-4 h-4" />
Server status
</h2>
<div>
<label
htmlFor="apiKey"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Key
</label>
<div className="relative">
<input
id="apiKey"
type={showKey ? "text" : "password"}
className="input pr-10 font-mono"
placeholder={
apiKeySet
? "API key saved — enter a new one to replace it"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-fg-muted hover:text-fg transition-colors"
aria-label={showKey ? "Hide API key" : "Show API key"}
tabIndex={-1}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border bg-bg-card/40">
<div className="flex items-center gap-3 min-w-0">
<StatusDot checking={checking} ok={Boolean(status)} />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">
{checking
? "Checking…"
: status
? "Connected"
: statusError
? "Unreachable"
: "Unknown"}
</p>
{status && (
<p className="text-xs text-fg-muted truncate">
Model: <span className="font-mono">{status.model}</span>
<br />
Endpoint:{" "}
<span className="font-mono">{status.endpoint}</span>
</p>
)}
</button>
{statusError && !checking && (
<p className="text-xs text-rose-300 break-words">
{statusError}
</p>
)}
</div>
</div>
{apiKeySet && !apiKey && (
<p className="mt-1 text-xs text-emerald-400">
A key is currently saved.
</p>
)}
{apiKeySet && (
<button
type="button"
onClick={onClearKey}
className="mt-2 inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Clear saved key
</button>
)}
</div>
<div>
<label
htmlFor="model"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
<button
type="button"
onClick={() => check()}
disabled={checking}
className="btn-ghost"
>
Model Name
</label>
<input
id="model"
type="text"
className="input"
placeholder="MiniMax-M3"
value={model}
onChange={(e) => setModel(e.target.value)}
required
/>
{checking ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
<span>Re-check</span>
</button>
</div>
<button
type="submit"
disabled={saving || testing}
className="btn-primary w-full py-3"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Configuration
</button>
<p className="text-xs text-fg-muted">
The LLM provider's API key lives on this server in the{" "}
<code className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
LLM_API_KEY
</code>{" "}
environment variable. The browser never sees it.
</p>
</div>
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2>
<p className="text-xs text-fg-muted mb-3">
MelodyMuse keeps some data in this browser. None of it leaves your
device except for the generation requests themselves.
</p>
<ul className="text-xs text-fg-muted space-y-1 mb-4">
<li>
<code className="font-mono">melodymuse-history</code> {" "}
{summary.historyEntries} recent generation
{summary.historyEntries === 1 ? "" : "s"}
</li>
<li>
<code className="font-mono">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
</li>
</ul>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onTest}
disabled={saving || testing}
className="btn-secondary w-full py-3"
onClick={onClearHistory}
disabled={summary.historyEntries === 0}
className="btn-secondary"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Test Connection
<Trash2 className="w-4 h-4" />
Clear recent generations
</button>
</form>
)}
<button
type="button"
onClick={onClearAll}
className="btn-secondary text-rose-300 hover:text-rose-200"
>
<Trash2 className="w-4 h-4" />
Clear all local data
</button>
</div>
</section>
<p className="mt-6 text-center text-xs text-fg-muted">
Your settings are stored in this browser's local storage. Do not use
this app on a device that other people have access to.
<p className="text-center text-xs text-fg-muted">
API key on the server · Recent generations in this browser only
</p>
<div className="mt-4 text-center">
<div className="text-center">
<Link
to="/"
className="text-xs text-fg-muted hover:text-fg transition-colors"
@@ -279,3 +239,10 @@ export function SettingsPage() {
</div>
);
}
function StatusDot({ checking, ok }: { checking: boolean; ok: boolean }) {
if (checking)
return <Loader2 className="w-5 h-5 text-fg-muted animate-spin shrink-0" />;
if (ok) return <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />;
return <AlertTriangle className="w-5 h-5 text-rose-400 shrink-0" />;
}