Build standalone MelodyMuse SPA
Drop the Supabase backend and call the AI provider directly from the
browser. The endpoint URL, API key, and model name are stored in
localStorage and used for direct /chat/completions requests.
- src/lib/llm.ts: config persistence, direct fetch, JSON extraction,
shape validation, connection test
- src/lib/prompts.ts: full + partial-regeneration system prompts and
user-message builder
- src/lib/{api,supabase}.ts removed
- supabase/ directory removed
- @supabase/supabase-js dropped from package.json
- README updated to describe the standalone architecture and CORS caveats
- .gitignore: drop Supabase entries, exclude *.tsbuildinfo
This commit is contained in:
+319
@@ -0,0 +1,319 @@
|
||||
// Direct browser → 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.
|
||||
|
||||
import { buildSystemPrompt, buildUserMessage } from "./prompts";
|
||||
import type {
|
||||
ConfigDisplay,
|
||||
GenerateRequest,
|
||||
SongAssets,
|
||||
TestConnectionResult,
|
||||
} from "./types";
|
||||
|
||||
const STORAGE_KEY = "melodymuse-config";
|
||||
const DEFAULT_MODEL = "MiniMax-M3";
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// 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,
|
||||
): Promise<string> {
|
||||
const base = cfg.api_endpoint.replace(/\/+$/, "");
|
||||
const url = `${base}/chat/completions`;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${cfg.api_key}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (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.
|
||||
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})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// High-level operations
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// `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 generateSong(req: AllRequest): Promise<SongAssets>;
|
||||
export async function generateSong(
|
||||
req: PartialRequest,
|
||||
): Promise<Partial<SongAssets>>;
|
||||
export async function generateSong(
|
||||
req: GenerateRequest,
|
||||
): Promise<Partial<SongAssets>>;
|
||||
export async function generateSong(
|
||||
req: GenerateRequest,
|
||||
): Promise<Partial<SongAssets>> {
|
||||
const cfg = loadConfig();
|
||||
if (!isConfigured(cfg)) {
|
||||
throw new Error("API not configured. Please go to Settings.");
|
||||
}
|
||||
|
||||
const content = await callProvider(
|
||||
{
|
||||
model: cfg.model_name,
|
||||
max_tokens: 4000,
|
||||
messages: [
|
||||
{ role: "system", content: buildSystemPrompt(req) },
|
||||
{ role: "user", content: buildUserMessage(req) },
|
||||
],
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export async function testConnection(): Promise<TestConnectionResult> {
|
||||
const cfg = loadConfig();
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : "Connection test failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user