Files
MelodyMuse/src/lib/llm.ts
T
Jannik d8b25ec6ab 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.
2026-06-03 08:01:21 +02:00

119 lines
3.2 KiB
TypeScript

// Browser → server (proxy) → LLM client.
//
// 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 type { GenerateRequest, SongAssets } from "./types";
// 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) ?? "";
async function postJson<T>(
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<T> {
let resp: Response;
try {
resp = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not reach the MelodyMuse server. ` +
`Is it running? (Tried: ${API_BASE || window.location.origin}${path}. ` +
`Details: ${detail})`,
);
}
if (!resp.ok) {
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);
}
return (await resp.json()) as T;
}
export interface ServerStatus {
ok: boolean;
llm_configured: boolean;
model: string;
endpoint: string;
}
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,
): Promise<SongAssets>;
export async function generateSong(
req: PartialRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>> {
return postJson<Partial<SongAssets>>("/api/generate", req, 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,
);
return data.style;
}