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
+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" />;
}