// 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( path: string, body: unknown, signal?: AbortSignal, ): Promise { 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 { 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 ConfigResponse { videoAiLinks: import("./types").VideoAiLink[]; } export async function getConfig(signal?: AbortSignal): Promise { 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; } // Overloads mirror the section-specific shape of the server's response. type AllRequest = GenerateRequest & { section: "all" }; type PartialRequest = GenerateRequest & { section: Exclude; }; export async function generateSong( req: AllRequest, options?: GenerateOptions, ): Promise; export async function generateSong( req: PartialRequest, options?: GenerateOptions, ): Promise>; export async function generateSong( req: GenerateRequest, options?: GenerateOptions, ): Promise>; export async function generateSong( req: GenerateRequest, options?: GenerateOptions, ): Promise> { return postJson>("/api/generate", req, options?.signal); } export interface StyleRandomResponse { style: string; mode: "normal" | "crazy"; } export async function randomStyle( mode: "normal" | "crazy", signal?: AbortSignal, idea?: string, ): Promise { const data = await postJson( "/api/style/random", { mode, idea: idea ?? "" }, signal, ); return data.style; }