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