Files
MelodyMuse/src/lib/llm.ts
T

132 lines
3.7 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 ConfigResponse {
videoAiLinks: import("./types").VideoAiLink[];
}
export async function getConfig(signal?: AbortSignal): Promise<ConfigResponse> {
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<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,
idea?: string,
): Promise<string> {
const data = await postJson<StyleRandomResponse>(
"/api/style/random",
{ mode, idea: idea ?? "" },
signal,
);
return data.style;
}