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
+38
View File
@@ -0,0 +1,38 @@
# ─────────────────────────────────────────────────────────────────────────────
# MelodyMuse — server environment
#
# The Node server (`server.mjs`) reads these from `process.env`. Pass them in
# however you run the server:
#
# • Bare-metal: LLM_ENDPOINT=… LLM_API_KEY=… node server.mjs
# • Docker: -e LLM_ENDPOINT=… -e LLM_API_KEY=…
# • docker-compose: put them in a `.env` file next to compose.yml
# • Jannik-Cloud: AGE-encrypted `.env` generated by `generate-env.sh`
#
# `LLM_ENDPOINT` and `LLM_API_KEY` are required — the server refuses to start
# without them. The rest have sensible defaults.
# ─────────────────────────────────────────────────────────────────────────────
# OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1
LLM_ENDPOINT=https://api.minimax.chat/v1
# Your provider's secret key
LLM_API_KEY=
# Model name (default: MiniMax-M3)
LLM_MODEL=MiniMax-M3
# Listen port (default: 3000)
PORT=3000
# CORS origin. "*" allows any origin (fine for a single-user personal app).
# Set to a specific origin like "https://melodymuse.orfel.de" to lock it down.
CORS_ORIGIN=*
# ─────────────────────────────────────────────────────────────────────────────
# Vite dev environment (only used by `npm run dev`, not by the server)
# ─────────────────────────────────────────────────────────────────────────────
# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want
# the browser to talk to a server on a different origin.
# VITE_API_BASE_URL=http://localhost:3000
+392 -97
View File
@@ -1,46 +1,326 @@
# MelodyMuse
Generate all assets needed for a Suno AI song from a free-text description
Generate every asset you need for a Suno AI song from a free-text idea
titles, lyrics, style prompt, video prompts (Abstract / Cinematic / Hybrid),
and a YouTube description, then download everything as a ZIP.
and a YouTube description then download everything as a single ZIP.
The app runs **entirely in your browser** as a single static SPA. There is no
backend, no database, no third-party services: the only network calls are
direct `fetch` requests from your browser to the AI provider's
OpenAI-compatible `/chat/completions` endpoint, using credentials that you
paste into the Settings page. Those credentials are kept in this browser's
`localStorage`.
The app runs as a single Node.js process that serves both the built SPA and
the API the SPA calls. Your LLM provider's API key lives on the **server**
(env var), never in the browser.
## Features
```
[Browser] ──► [Node server :3000] ──► [LLM provider]
(no key) (holds the key)
```
- 🎵 **All 5 Suno assets in one click** — titles, lyrics, style, video prompts, YouTube description
- ✏️ **Inline editing** — every generated field is editable; revert any edit with one click
- 🔄 **Per-section regeneration** — regenerate just one section; the rest of the song is passed as context for consistency
-**Elapsed-time counter** + ❌ **Cancel** button for long generations
- 🕘 **Recent generations** — last 6 are kept in localStorage; one click to reload
- ⌨️ **Keyboard shortcut**`Cmd/Ctrl + Enter` to generate
- 🎲 **Try an example** — fill the input with a random sample idea
- 🌗 **Dark / light mode** — toggle in the header, persisted
- 💾 **Standalone** — no backend, no signup, no telemetry
---
## Tech stack
## Table of contents
- React 18 + TypeScript + Vite
- Tailwind CSS (dark by default, light theme via `html.light`)
- JSZip for client-side ZIP generation
- `react-router-dom` for the two routes (`/`, `/settings`)
1. [Quick start](#quick-start)
2. [How to use the app](#how-to-use-the-app)
- [The main page](#the-main-page)
- [The five result cards](#the-five-result-cards)
- [The settings page](#the-settings-page)
- [Keyboard shortcuts](#keyboard-shortcuts)
3. [Features in depth](#features-in-depth)
- [Surprise me / Go crazy (style buttons)](#surprise-me--go-crazy-style-buttons)
- [Regenerate one section](#regenerate-one-section)
- [Edit, then Revert](#edit-then-revert)
- [Recent generations](#recent-generations)
- [Download the ZIP](#download-the-zip)
- [Cancel a long generation](#cancel-a-long-generation)
4. [Tips & tricks](#tips--tricks)
5. [Troubleshooting](#troubleshooting)
6. [For developers](#for-developers)
## Repository layout
---
## Quick start
You need **Node.js 18+** on your machine (or a server you can deploy to).
```sh
# 1. Clone
git clone https://git.orfel.de/Jannik/MelodyMuse.git
cd MelodyMuse
# 2. Install
npm install
# 3. Tell the server where your LLM lives and which key to use
export LLM_ENDPOINT="https://api.minimax.chat/v1"
export LLM_API_KEY="sk-…"
# Optional:
export LLM_MODEL="MiniMax-M3" # default
# 4. Run the dev server (two terminals — see below)
```
### Dev workflow (two terminals)
```sh
# Terminal 1 — the API/proxy server (port 3000)
npm run dev:server
# Terminal 2 — the Vite dev server (port 5173, hot reload)
npm run dev
```
Open <http://localhost:5173>. The Vite dev server proxies `/api/*` to the
Node server on `:3000` (see `vite.config.ts`), so the SPA always talks to
the same origin.
### Production
```sh
npm run build # writes the static SPA to dist/
npm start # node server.mjs, serves dist/ + /api/*
```
Then put a reverse proxy (Caddy, nginx, …) in front of `localhost:3000`.
### Docker / Jannik-Cloud
Reference files are in [`deploy/`](./deploy). See
[`deploy/README.md`](./deploy/README.md) for the full deploy guide.
---
## How to use the app
### The main page
Two columns on desktop, stacked on mobile.
**Left — the input panel**
- **Music idea** — describe what you want. The more concrete, the better the
result. Press <kbd>⌘/Ctrl + Enter</kbd> to generate.
- **Try an example** — fills the input with a random starter idea.
- **Options** — click to expand:
- **Language** — lyrics language. Choose "Other" to type a custom one.
- **Music style** — an optional style description. Two buttons:
- **Surprise me** — generates a normal, Suno-friendly style.
- **Go crazy** — generates an unusual genre mashup.
- **Mood** *(optional)* — an emotional hint, e.g. "melancholic".
- **Vocals** — 🎤 Vocals or 🎹 Instrumental.
- **Generate Song Assets** — main button. Spinner + elapsed time during
generation. **Cancel generation** button appears next to it.
- **Recent generations** (below the form) — last 6 saved generations.
**Right — the results panel**
Shows the five result cards once a generation completes. Each card is
editable and individually regenerable. The sticky results header has a
"Regenerate All" button. Once you've picked a title, the **Download ZIP**
bar appears at the bottom of the viewport.
### The five result cards
| Card | What it does | Editable? | Regenerate? |
|---|---|---|---|
| 🏷️ **Song Titles** | 3 short title suggestions. Click to select. | select only | yes |
| 📝 **Lyrics** | Full lyrics with Suno section tags. | yes | yes |
| 🎵 **Style** | Style prompt **and** negative style. | yes | yes (both at once) |
| 🎬 **Video Prompts** | Abstract / Cinematic / Hybrid tabs. | yes | yes |
| 📺 **YouTube Description** | Full description with embedded lyrics + hashtags. | yes | yes |
Each editable card has a **Revert** button (top-right) that appears the
moment you change the value. Click it to restore the last generated version.
### The settings page
Click **Settings** (under the generate button) or navigate to `/settings`.
- **Server status** — green check if the API server is reachable and
configured. Click **Re-check** to refresh. Shows the model name and
endpoint so you know which LLM the server is using.
- **Local data** — see what's stored in your browser (`melodymuse-history`
for recent generations, `melodymuse-theme` for the theme). You can
clear individual buckets or everything.
- **Theme** — toggle the moon/sun icon in the header.
The API key is **not** here — it lives on the server in the `LLM_API_KEY`
environment variable.
### Keyboard shortcuts
| Key | Action |
|---|---|
| <kbd>⌘/Ctrl + Enter</kbd> in the idea textarea | Generate |
| <kbd>Esc</kbd> while a generation is in flight | Cancel it |
---
## Features in depth
### Surprise me / Go crazy (style buttons)
Both buttons are in the **Options → Music style** row.
| Button | What the model is told |
|---|---|
| ✨ **Surprise me** | "Generate ONE short, cohesive, production-ready Suno style description. Comma-separated keywords. Max 25 words." |
| 🔥 **Go crazy** | "Generate ONE short style description that DELIBERATELY combines genres/eras/instruments that don't normally mix. Still parseable by Suno. Max 25 words." |
When you click a button:
1. The button shows a spinner.
2. The browser calls `POST /api/style/random` on the server.
3. The server calls the LLM with a small dedicated system prompt.
4. The returned one-line style description replaces the value in the
**Music style** textarea.
5. The spinner stops.
You can keep clicking — each click fetches a new style. The field is
overwritten, never appended. Both buttons are disabled while a request is
in flight, so you can't double-fire.
If the field has a value when you click **Generate Song Assets**, that value
is sent to the model as a `style_hint` and used as the basis for the full
`style` field in the output.
### Regenerate one section
Every card has a **Regenerate** button in its header.
- Click it to ask the model to rewrite that section **only**.
- The rest of the current song is passed as `context` so the new section
stays consistent with the existing lyrics, mood, and vocabulary.
- Only that card's spinner is active while it runs; the other cards stay
fully usable.
### Edit, then Revert
Every editable card compares its current value against the last generated
value. If they differ, a **Revert** button appears in the card header.
Click it to snap back to the last generated version.
This is great for "I'll tweak this one line and see if I like it better"
without losing the original.
### Recent generations
The last 6 successful generations are saved in `localStorage` under
`melodymuse-history`. Click any entry to restore both the **input form
values** AND the full **generated assets** — handy for comparing two
generations of the same idea.
Individual entries have an ✕ button to remove them. There's a **Clear all**
button at the bottom of the history panel.
### Download the ZIP
Once you've picked a title (the first one is auto-selected on generation),
a sticky bar appears at the bottom of the viewport showing
`📁 [Title].zip` and a **Download ZIP** button. Click it to download a ZIP
with four files:
| File | Contents |
|---|---|
| `Style.txt` | `STYLE PROMPT: …` + `NEGATIVE STYLE: …` |
| `Text.txt` | The full lyrics |
| `Videodescription.txt` | The YouTube description |
| `Videoprompt.txt` | All three video prompts in order (Abstract, Cinematic, Hybrid), each with the negative line and tool recommendation |
The filename is the selected title, sanitized: spaces become `_`, anything
that's not a letter, digit, underscore, or dash is removed. If the title
sanitizes to an empty string, the file is named `song.zip`.
### Cancel a long generation
While a generation is running:
- A **Cancel generation** button appears below the main generate button.
- A **Cancel** button appears in the sticky results header (top right of
the right panel).
- The Generate button shows a live elapsed-time counter
(e.g. `Generating… 8s`).
- <kbd>Esc</kbd> also works.
Cancellation aborts the in-flight HTTP request. The server also aborts
its upstream request to the LLM, so no tokens are wasted.
---
## Tips & tricks
- **Be specific in the idea.** "Melancholic lo-fi house beat for a rainy
Sunday morning" produces better results than "make a song". Mood,
tempo, era, setting, instrumentation — all help.
- **Use Surprise me to break out of a rut.** Click it 34 times; one of
the styles will spark a new direction.
- **Use Go crazy for instant novelty.** "Baroque chamber orchestra meets
dubstep" might be exactly the brief you needed.
- **Style hint + surprise = best of both.** Click Surprise me, then tweak
the words slightly before generating. The model uses your edit as the
basis for the full 120-word style description.
- **Revert before regenerating.** If you edited a card and the edit isn't
quite right, click Revert to restore the last generated version, then
click Regenerate to get a fresh alternative.
- **History is your scratch pad.** If you generate 6 variations, none of
them is lost — pick the best from history.
---
## Troubleshooting
### "Server offline" / "Server unreachable"
The SPA can't reach the Node server. Check:
- The server is running (`npm start` or `node server.mjs`).
- `LLM_ENDPOINT` and `LLM_API_KEY` are set in the server's env.
- Open <http://localhost:3000/api/health> — it should return
`{ "ok": true, ... }`.
### "Provider returned 401 / 403"
The `LLM_API_KEY` is wrong, expired, or doesn't have access to the model.
Update the env var on the server and restart.
### "Provider returned 404"
Either the `LLM_ENDPOINT` is wrong, the path `/chat/completions` doesn't
exist at that URL, or the model name (`LLM_MODEL`) doesn't exist for that
provider.
### "Could not reach ${url}" with `fetch failed`
The server can't reach the LLM provider. If you're running the server in
a container, check that it has network access to the provider. Some
providers block known cloud-IP ranges — check the provider's allow-list.
### The model returns prose instead of JSON
The system prompt asks for JSON only. If a model ignores that, the
server's `extractJson` tries three increasingly lenient fallbacks. If all
fail you'll see a clear error toast — re-try the generation, or try a
different model.
### "Storage quota exceeded" toast
Your `localStorage` is full (a typical cap is 510 MB). Open Settings →
Local data → Clear all local data, or just clear recent generations.
### I can't get a clean style out of "Go crazy"
The prompt is intentionally permissive — the model is told to "deliberately
break conventions". If you get a style that Suno rejects, just click the
button again.
---
## For developers
### Project layout
```
MelodyMuse/
├── src/
├── src/ # React SPA
│ ├── App.tsx
│ ├── main.tsx
│ ├── index.css # Tailwind layers + design-system components
│ ├── components/ # UI building blocks
│ │ ├── cards/ # The five result cards
│ │ ├── ConfigBanner.tsx
│ ├── index.css # Tailwind + design tokens
│ ├── components/ # UI primitives
│ │ ├── cards/ # The 5 result cards
│ │ ├── CopyButton.tsx
│ │ ├── EqualizerIcon.tsx
│ │ ├── Footer.tsx
@@ -51,100 +331,115 @@ MelodyMuse/
│ │ ├── SkeletonCard.tsx
│ │ ├── StickyZipBar.tsx
│ │ └── ThemeToggle.tsx
│ ├── pages/ # HomePage, SettingsPage
│ ├── lib/
│ │ ── history.ts # recent-generations persistence
│ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence
├── prompts.ts # system + user prompt construction
├── theme.ts # dark/light mode
├── toast.tsx # toast context
├── types.ts # shared TypeScript types + SECTION_LABELS
├── useAutoHeight.ts # shared textarea auto-grow hook
├── useElapsed.ts # shared "X seconds elapsed" hook
── zip.ts # JSZip layout
└── vite-env.d.ts
│ ├── pages/
│ ├── HomePage.tsx
│ │ ── SettingsPage.tsx
└── lib/
├── history.ts # recent generations persistence
├── llm.ts # browser → server client (thin)
├── theme.ts # dark/light + localStorage
├── toast.tsx # toast context
├── types.ts # shared TS types
├── useAutoHeight.ts # textarea auto-grow hook
── useElapsed.ts # elapsed-seconds hook
└── zip.ts # JSZip layout
├── server/
│ └── prompts.mjs # system + user prompt construction
├── server.mjs # ★ the runtime — serves SPA + /api/*
├── deploy/ # reference Dockerfile, compose, caddy, env script
├── public/favicon.svg
├── index.html
├── tailwind.config.js
├── vite.config.ts
├── tsconfig*.json
── package.json
── package.json
└── README.md
```
## Getting started
### Architecture
1. **Install dependencies**
The browser only ever talks to one server: the Node process in
`server.mjs`. That server:
- Serves the built SPA from `dist/`
- Exposes three JSON endpoints:
- `GET /api/health` — health check
- `POST /api/generate` — main generation
- `POST /api/style/random` — single-line Suno style
- Holds the LLM credentials in `process.env`
```sh
npm install
```
The browser-side code is a thin wrapper around `fetch`. The system
prompts, JSON extraction, and shape validation all live on the server in
`server/prompts.mjs` and `server.mjs`. The browser never sees the
provider, the key, or the prompts.
2. **Start the dev server**
### Scripts
```sh
npm run dev
```
| Command | What it does |
|---|---|
| `npm run dev` | Vite dev server (port 5173) with HMR. Proxies `/api/*` to `:3000`. |
| `npm run dev:server` | The Node server in dev (no build step). |
| `npm run build` | Type-check + build the SPA to `dist/`. |
| `npm start` | Run the Node server (uses the existing `dist/`). |
| `npm run preview` | Vite preview server (no HMR). |
| `npm run lint` | Type-check only. |
Open <http://localhost:5173> in your browser.
### Environment variables (server)
3. **Open Settings** and fill in:
| Var | Required | Default | Notes |
|---|---|---|---|
| `LLM_ENDPOINT` | ✅ | — | e.g. `https://api.minimax.chat/v1` |
| `LLM_API_KEY` | ✅ | — | Provider secret |
| `LLM_MODEL` | | `MiniMax-M3` | |
| `PORT` | | `3000` | |
| `CORS_ORIGIN` | | `*` | Lock this down in production |
- **API Endpoint URL** — e.g. `https://api.minimax.chat/v1`
- **API Key** — your provider's secret key
- **Model Name** — e.g. `MiniMax-M3`
### Environment variables (Vite, dev only)
Click **Save Configuration**, then **Test Connection** to confirm
everything is wired up. **Test Connection** uses your unsaved form
values — your changes are only persisted when you click **Save**.
| Var | Default | Notes |
|---|---|---|
| `VITE_API_BASE_URL` | empty | Override the API base URL. Leave empty in dev (Vite's proxy handles it) and in same-origin production deployments. |
4. **Generate**. Return to the home page, type a music idea, click
**Generate Song Assets** (or press `Cmd/Ctrl + Enter`).
### API contracts
## Build for production
`POST /api/generate` — body:
```sh
npm run build
```json
{
"input": "user's music idea (required)",
"language": "English",
"mood": "optional",
"style_hint": "optional — short style description",
"vocals": "vocals | instrumental",
"section": "all | lyrics | style | titles | video_prompts | youtube_description",
"context": { /* full or partial SongAssets, used for partial regen */ }
}
```
Outputs static assets in `dist/`. The `dist/` folder is a normal SPA — serve
it from any static host (GitHub Pages, Netlify, Vercel, `python -m http.server`,
…).
`POST /api/style/random` — body:
> ⚠️ **Heads up about deployment.** Because the API key is stored in the
> browser's `localStorage`, you should not serve a public deployment of this
> app and use it with a real key on a shared device. For personal/local use
> this is fine.
```json
{ "mode": "normal | crazy" }
```
## CORS
Response: `{ "style": "…", "mode": "…" }`.
The app makes direct cross-origin requests from the browser to your
provider. If your provider does not send the right `Access-Control-Allow-*`
headers for your origin, the request will fail with a CORS error. The
generated error message will explicitly call this out. Workarounds:
`GET /api/health` — response:
- Pick a provider/endpoint that already permits browser CORS (most managed
OpenAI-compatible services do).
- Run the app on the same origin as the API (i.e. front it with a tiny
proxy).
- Use a CORS-permissive browser extension during local development.
```json
{ "ok": true, "llm_configured": true, "model": "MiniMax-M3", "endpoint": "https://…" }
```
## Provider format
### Security notes
The configured endpoint must expose an OpenAI-compatible
`POST {api_endpoint}/chat/completions` route that accepts
`{ model, messages, max_tokens }` and returns
`{ choices: [{ message: { content } }] }`.
## Security notes
- The API key is held in `localStorage` and is only sent to the endpoint you
configure. No analytics, no telemetry, no third-party calls.
- Three localStorage keys are used:
- `melodymuse-config` — `{ api_endpoint, api_key, model_name }`
- `melodymuse-history` — the last 6 generations (input + assets)
- `melodymuse-theme` — `'dark' | 'light'`
- You can clear them at any time from your browser's devtools
(Application → Local Storage).
- The API key is held by the Node process via `process.env`. It's never
sent to the browser in any response, not even in the health check.
- The `localStorage` keys the SPA writes:
- `melodymuse-history` — last 6 generations
- `melodymuse-theme``'dark'` or `'light'`
- `melodymuse-config` — reserved, currently unused (kept for future
client-side server-URL override)
- You can wipe any of them from Settings → Local data, or programmatically
with the browser's DevTools.
## License
+43
View File
@@ -0,0 +1,43 @@
# Multi-stage Dockerfile for MelodyMuse.
#
# This file lives in the MelodyMuse repo under `deploy/` as a reference. To
# add MelodyMuse to a Docker-Compose deployment (including your Jannik-Cloud
# stack), copy this file to your service directory and reference it from
# docker-compose.yml — see `docker-compose.example.yml`.
# Stage 1 — build the SPA
FROM node:20-alpine AS builder
RUN apk add --no-cache git
# Override these at build time if you fork the repo.
ARG REPO_URL=https://git.orfel.de/Jannik/MelodyMuse.git
ARG BRANCH=main
WORKDIR /build
RUN git clone --depth 1 --branch ${BRANCH} ${REPO_URL} .
RUN npm ci
RUN npm run build
# Stage 2 — runtime: just Node + the built assets + the proxy server
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/server.mjs ./server.mjs
COPY --from=builder /build/server ./server
COPY --from=builder /build/package.json ./package.json
# server.mjs uses only Node's built-ins — no production deps to install.
# Install nothing here; the image stays small.
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# Quick healthcheck so Docker / your orchestrator can detect a broken boot.
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "server.mjs"]
+78
View File
@@ -0,0 +1,78 @@
# Deploying MelodyMuse
The files in this folder are **reference templates**. MelodyMuse is a single
Node.js server (`server.mjs` in the repo root) that serves the built SPA and
proxies LLM calls. To deploy it you can:
- Run `node server.mjs` directly (after `npm run build`)
- Use the included `Dockerfile` + `docker-compose.yml`
- Copy these into your Jannik-Cloud `services/melodymuse/` directory
## Required environment variables
| Var | Required? | Default | Notes |
|---|---|---|---|
| `LLM_ENDPOINT` | **yes** | — | OpenAI-compatible base URL, e.g. `https://api.minimax.chat/v1` |
| `LLM_API_KEY` | **yes** | — | Provider secret key. **Never** set this in the browser. |
| `LLM_MODEL` | no | `MiniMax-M3` | Model name |
| `PORT` | no | `3000` | Listen port |
| `CORS_ORIGIN` | no | `*` | Set to a specific origin in production if you split SPA and server |
The server refuses to start if `LLM_ENDPOINT` or `LLM_API_KEY` is missing.
## Option 1 — Bare Node
```sh
# Clone, build, run
git clone https://git.orfel.de/Jannik/MelodyMuse.git
cd MelodyMuse
npm ci
npm run build
# Set the secrets and start
export LLM_ENDPOINT=https://api.minimax.chat/v1
export LLM_API_KEY=sk-...
npm start
```
## Option 2 — Docker
```sh
# Build
docker build -f deploy/Dockerfile -t melodymuse .
# Run
docker run --rm -p 3000:3000 \
-e LLM_ENDPOINT=https://api.minimax.chat/v1 \
-e LLM_API_KEY=sk-... \
melodymuse
```
## Option 3 — Add to Jannik-Cloud
1. Create `services/melodymuse/` in your Jannik-Cloud repo.
2. Copy the four files from this folder into it:
- `Dockerfile`
- `docker-compose.yml` (rename from `docker-compose.example.yml`)
- `melodymuse.caddy`
- `generate-env.sh`
3. `touch services/melodymuse/service.enabled`
4. `bash services/melodymuse/generate-env.sh` — answer the prompts.
5. Commit `services/melodymuse/.env.age` and the four non-secret files.
6. `sudo bash /opt/Jannik-Cloud/deploy_script.sh` — Caddy will pick up
`melodymuse.orfel.de` and route it to the container.
## Endpoints exposed by the server
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/health` | Health check. Returns `{ ok, llm_configured, model, endpoint }`. |
| `POST` | `/api/generate` | Main generation. Body matches the `GenerateRequest` shape. |
| `POST` | `/api/style/random` | Returns a one-line Suno style. Body: `{ mode: "normal" \| "crazy" }`. |
| `GET` | everything else | Serves the built SPA from `dist/`, with SPA fallback to `index.html`. |
## Image footprint
The runtime image is `node:20-alpine` with only `server.mjs` and the
built SPA. No production `npm install` (server.mjs uses only Node
built-ins). Total image size is roughly 200 MB.
+34
View File
@@ -0,0 +1,34 @@
# Example docker-compose snippet for MelodyMuse.
#
# Drop this into your service directory (e.g. `services/melodymuse/` in a
# Jannik-Cloud-style stack) and rename to `docker-compose.yml`. Pair it with
# `Dockerfile`, `melodymuse.caddy`, and `generate-env.sh` from the same folder.
services:
melodymuse:
build:
context: .
dockerfile: Dockerfile
image: melodymuse:latest
container_name: melodymuse
restart: unless-stopped
ports:
# Only loopback — let your reverse proxy (Caddy) handle public traffic.
- "127.0.0.1:3000:3000"
environment:
LLM_ENDPOINT: ${LLM_ENDPOINT}
LLM_API_KEY: ${LLM_API_KEY}
LLM_MODEL: ${LLM_MODEL:-MiniMax-M3}
PORT: ${PORT:-3000}
CORS_ORIGIN: ${CORS_ORIGIN:-*}
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
networks:
- jannik-cloud-net
networks:
jannik-cloud-net:
external: true
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# generate-env.sh — create the MelodyMuse `.env` (and AGE-encrypt it).
#
# Usage: bash generate-env.sh
#
# Customize LLM_ENDPOINT / LLM_MODEL below before running. The script prompts
# for the LLM_API_KEY so it doesn't end up in your shell history.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
AGE_PUB_KEY="${REPO_ROOT}/keys/age-public-key.txt"
# ─── Customize these for your deployment ─────────────────────────────────────
LLM_ENDPOINT_DEFAULT="https://api.minimax.chat/v1"
LLM_MODEL_DEFAULT="MiniMax-M3"
# ──────────────────────────────────────────────────────────────────────────────
echo "LLM endpoint [${LLM_ENDPOINT_DEFAULT}]:"
read -r LLM_ENDPOINT
LLM_ENDPOINT="${LLM_ENDPOINT:-$LLM_ENDPOINT_DEFAULT}"
echo "LLM model [${LLM_MODEL_DEFAULT}]:"
read -r LLM_MODEL
LLM_MODEL="${LLM_MODEL:-$LLM_MODEL_DEFAULT}"
echo "LLM API key (input is hidden):"
read -rs LLM_API_KEY
echo ""
if [[ -z "${LLM_API_KEY}" ]]; then
echo "ERROR: LLM_API_KEY is required."
exit 1
fi
cat > "${SCRIPT_DIR}/.env" <<EOF
# MelodyMuse — generated by generate-env.sh
LLM_ENDPOINT=${LLM_ENDPOINT}
LLM_API_KEY=${LLM_API_KEY}
LLM_MODEL=${LLM_MODEL}
PORT=3000
CORS_ORIGIN=*
EOF
chmod 600 "${SCRIPT_DIR}/.env"
if [[ -f "${AGE_PUB_KEY}" ]]; then
age -r "$(cat "${AGE_PUB_KEY}")" -o "${SCRIPT_DIR}/.env.age" "${SCRIPT_DIR}/.env"
echo ""
echo "Encrypted .env → .env.age"
echo "Commit .env.age (NOT .env) to your repo, then re-run deploy."
else
echo ""
echo "No AGE public key found at ${AGE_PUB_KEY}."
echo "Encrypt manually before committing, e.g.:"
echo " age -r <recipient> -o .env.age .env"
fi
+8
View File
@@ -0,0 +1,8 @@
# Caddy reverse proxy for MelodyMuse.
# Drop this into your service directory as `melodymuse.caddy` — your
# Caddyfile (or `caddy` Docker image) will pick it up automatically.
melodymuse.orfel.de {
encode zstd gzip
reverse_proxy melodymuse:3000
}
+4 -2
View File
@@ -1,12 +1,14 @@
{
"name": "melodymuse",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"description": "Generate all assets needed for a Suno AI song from a free-text input. Runs entirely in the browser — no backend.",
"description": "Generate all assets needed for a Suno AI song from a free-text input. The LLM API key lives on the server.",
"scripts": {
"dev": "vite",
"dev:server": "node server.mjs",
"build": "tsc -b && vite build",
"start": "node server.mjs",
"preview": "vite preview",
"lint": "tsc -b --noEmit"
},
+444
View File
@@ -0,0 +1,444 @@
// MelodyMuse — single-file production server.
//
// Responsibilities:
// 1. Serve the built Vite SPA from `./dist/` (SPA fallback to index.html).
// 2. Proxy LLM calls through three JSON endpoints so the browser never
// sees the API key.
//
// Run with: node server.mjs
// Or via: npm start
//
// Required env vars (the server exits if any are missing):
// LLM_ENDPOINT — OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1
// LLM_API_KEY — provider secret key
// Optional env vars:
// LLM_MODEL — model name (default: MiniMax-M3)
// PORT — listen port (default: 3000)
// CORS_ORIGIN — "*" to allow any origin, or a specific origin
// (default: "*", fine for single-user personal use)
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildStylePrompt,
buildSystemPrompt,
buildUserMessage,
} from './server/prompts.mjs';
// ──────────────────────────────────────────────────────────────────────────────
// Configuration
// ──────────────────────────────────────────────────────────────────────────────
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const DIST = join(__dirname, 'dist');
const PORT = parseInt(process.env.PORT || '3000', 10);
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const LLM = {
endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''),
apiKey: process.env.LLM_API_KEY || '',
model: process.env.LLM_MODEL || 'MiniMax-M3',
};
if (!LLM.endpoint || !LLM.apiKey) {
console.error('');
console.error(' [FATAL] LLM_ENDPOINT and LLM_API_KEY must be set.');
console.error(` LLM_ENDPOINT: ${LLM.endpoint || '<missing>'}`);
console.error(` LLM_API_KEY: ${LLM.apiKey ? '********' : '<missing>'}`);
console.error('');
process.exit(1);
}
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
console.log(`[startup] LLM model: ${LLM.model}`);
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation
// ──────────────────────────────────────────────────────────────────────────────
function isObject(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
// Pull a JSON object out of a model reply that may have wrapped it in
// ```json ... ``` fences or added a brief preamble.
function extractJson(text) {
const trimmed = text.trim();
try { return JSON.parse(trimmed); } catch { /* fall through */ }
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced) {
try { return JSON.parse(fenced[1].trim()); } catch { /* fall through */ }
}
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');
}
const REQUIRED_KEYS = [
'titles',
'lyrics',
'style',
'negative_style',
'youtube_description',
'video_prompts',
];
function validateShape(parsed) {
if (!isObject(parsed)) throw new Error('Model response is not an object');
for (const k of REQUIRED_KEYS) {
if (!(k in parsed)) continue; // partial regen may omit keys
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`);
}
}
}
function validateFullAssets(parsed) {
if (!isObject(parsed)) throw new Error('Model response is not an object');
for (const k of REQUIRED_KEYS) {
if (!(k in parsed)) {
throw new Error(`Model response is missing required key "${k}"`);
}
}
validateShape(parsed);
}
// For partial regen, drop any keys the model returned that we didn't ask for.
// Keeps the merged state predictable.
function pickResultForSection(section, result) {
switch (section) {
case 'titles':
return { titles: result.titles };
case 'lyrics':
return { lyrics: result.lyrics };
case 'style':
return {
style: result.style,
negative_style: result.negative_style,
};
case 'video_prompts':
return { video_prompts: result.video_prompts };
case 'youtube_description':
return { youtube_description: result.youtube_description };
default:
return result;
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Provider call
// ──────────────────────────────────────────────────────────────────────────────
async function callProvider(messages, opts = {}, signal) {
const url = `${LLM.endpoint}/chat/completions`;
let resp;
try {
resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM.apiKey}`,
},
body: JSON.stringify({
model: LLM.model,
max_tokens: opts.max_tokens ?? 4000,
messages,
}),
signal,
});
} catch (err) {
if (err && err.name === 'AbortError') throw err;
throw new Error(
`Could not reach ${url}. Check LLM_ENDPOINT and that the provider is reachable from this server. (Details: ${err.message})`,
);
}
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ''}`,
);
}
const data = await resp.json();
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;
}
// Strip a leading "Style: " or surrounding quotes that some models like to add.
function cleanStyleString(s) {
let out = s.trim();
// Strip a single pair of wrapping quotes, either kind.
if (
(out.startsWith('"') && out.endsWith('"')) ||
(out.startsWith("'") && out.endsWith("'"))
) {
out = out.slice(1, -1).trim();
}
// Strip a "Style:" / "Style prompt:" prefix if the model added one.
out = out.replace(/^(style\s*(prompt)?\s*[:\-]\s*)/i, '');
// Strip a trailing period.
out = out.replace(/\.+$/, '').trim();
return out;
}
// ──────────────────────────────────────────────────────────────────────────────
// HTTP helpers
// ──────────────────────────────────────────────────────────────────────────────
const STATIC_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2':'font/woff2',
'.ttf': 'font/ttf',
'.map': 'application/json; charset=utf-8',
};
function json(res, body, status = 200) {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(body));
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk) => {
data += chunk;
// Guard against runaway payloads.
if (data.length > 1_000_000) {
reject(new Error('Request body too large'));
req.destroy();
}
});
req.on('end', () => {
if (!data) return resolve({});
try { resolve(JSON.parse(data)); }
catch { reject(new Error('Invalid JSON body')); }
});
req.on('error', reject);
});
}
// Combined abort signal: the request stream closing (client disconnect) OR
// the explicit AbortSignal passed in.
function combinedSignal(req, explicit) {
const ctrl = new AbortController();
const onClose = () => ctrl.abort(new Error('client disconnected'));
req.once('close', onClose);
if (explicit) {
if (explicit.aborted) ctrl.abort(explicit.reason);
else explicit.addEventListener('abort', () => ctrl.abort(explicit.reason), { once: true });
}
return ctrl.signal;
}
async function serveStatic(req, res) {
// Strip query string and decode.
const urlPath = decodeURIComponent(req.url.split('?')[0]);
// Disallow path traversal.
if (urlPath.includes('..')) {
res.writeHead(400); res.end('Bad request'); return;
}
let filePath = join(DIST, urlPath);
try {
const s = await stat(filePath);
if (s.isDirectory()) filePath = join(filePath, 'index.html');
} catch {
// SPA fallback — serve index.html for unknown routes.
filePath = join(DIST, 'index.html');
}
try {
const data = await readFile(filePath);
const ext = extname(filePath).toLowerCase();
const type = STATIC_TYPES[ext] || 'application/octet-stream';
// Hashed Vite assets get long-lived caching; index.html and the un-hashed
// root never get cached so deploys take effect immediately.
const isHashedAsset = /\/assets\//.test(urlPath) && /\.[a-z0-9]{8}\./.test(urlPath);
const cacheControl = isHashedAsset
? 'public, max-age=31536000, immutable'
: 'no-cache';
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl });
res.end(data);
} catch (err) {
res.writeHead(500); res.end('Internal error');
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Route handlers
// ──────────────────────────────────────────────────────────────────────────────
function applyCors(res) {
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
}
async function handleHealth(req, res) {
json(res, {
ok: true,
llm_configured: true,
model: LLM.model,
endpoint: LLM.endpoint,
});
}
async function handleStyleRandom(req, res) {
let body;
try { body = await readJsonBody(req); }
catch (err) { return json(res, { error: err.message }, 400); }
const mode = body?.mode === 'crazy' ? 'crazy' : 'normal';
const signal = combinedSignal(req);
try {
const content = await callProvider(
[
{ role: 'system', content: buildStylePrompt(mode) },
{ role: 'user', content: 'Generate one style description now.' },
],
{ max_tokens: 120 },
signal,
);
const style = cleanStyleString(content);
if (!style) throw new Error('Model returned an empty style');
json(res, { style, mode });
} catch (err) {
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
return; // Client gave up; no point responding.
}
console.error('[style/random]', err);
json(res, { error: err.message || 'Failed to generate style' }, 500);
}
}
async function handleGenerate(req, res) {
let body;
try { body = await readJsonBody(req); }
catch (err) { return json(res, { error: err.message }, 400); }
if (!body || typeof body.input !== 'string' || !body.input.trim()) {
return json(res, { error: 'Missing "input"' }, 400);
}
if (!body.section) body.section = 'all';
if (!body.vocals) body.vocals = 'vocals';
if (!body.language) body.language = 'English';
const signal = combinedSignal(req);
try {
const content = await callProvider(
[
{ role: 'system', content: buildSystemPrompt(body) },
{ role: 'user', content: buildUserMessage(body) },
],
{ max_tokens: 4000 },
signal,
);
const parsed = extractJson(content);
if (body.section === 'all') {
validateFullAssets(parsed);
} else {
validateShape(parsed);
}
const result = body.section === 'all' ? parsed : pickResultForSection(body.section, parsed);
json(res, result);
} catch (err) {
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
return;
}
console.error('[generate]', err);
json(res, { error: err.message || 'Generation failed' }, 500);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Server
// ──────────────────────────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
applyCors(res);
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// API
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
return handleHealth(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
return handleGenerate(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') {
return handleStyleRandom(req, res);
}
// Anything under /api that didn't match is a 404 (don't fall through to the SPA).
if (req.url.startsWith('/api/')) {
return json(res, { error: 'Not found' }, 404);
}
if (req.method === 'GET') {
return serveStatic(req, res);
}
res.writeHead(405);
res.end('Method not allowed');
});
server.listen(PORT, '0.0.0.0', () => {
// The boot log already happened above.
});
// Graceful shutdown so the process exits cleanly when Docker stops it.
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
console.log(`[shutdown] ${sig} received, closing server…`);
server.close(() => process.exit(0));
// Hard exit if it takes too long.
setTimeout(() => process.exit(1), 5000).unref();
});
}
+56 -9
View File
@@ -1,7 +1,5 @@
// System + user prompt construction for the LLM. Keeping it in its own module
// avoids a 200-line string literal in the llm module entry point.
import type { GenerateRequest } from './types';
// System + user prompt construction for the LLM. Server-side only — the
// browser never sees these prompts.
export const SYSTEM_PROMPT_ALL = `You are a music production assistant specializing in Suno AI song creation.
@@ -14,6 +12,12 @@ LANGUAGE RULES (strict):
- "style", "negative_style", and all "video_prompts" ALWAYS English, regardless
of the language field
STYLE HINT (optional):
- If the user provided a "style_hint" (a short style description in the user
message), use it as the basis for the "style" field. Expand and refine it
into the full 120-word comma-separated Suno style description required below.
- If no style_hint is provided, invent a coherent style from scratch.
REQUIRED JSON STRUCTURE:
{
"titles": [
@@ -48,7 +52,7 @@ Keep each video_prompts[].prompt under 900 characters (excluding the Negative li
Recommended video tools (choose the best fit per prompt): Runway Gen-3 Alpha, Kling AI,
Luma Dream Machine, Pika 2.0, Haiper. Never recommend Sora.`;
const PARTIAL_SYSTEM_PROMPT = `You are a music production assistant specializing in Suno AI song creation.
const SYSTEM_PROMPT_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
The user has an existing song and wants to regenerate ONE section. Output ONLY
valid JSON no markdown, no backticks, no preamble.
@@ -80,16 +84,55 @@ QUALITY CONSTRAINTS:
- youtube_description: follow the hook description divider lyrics
divider CTA hashtags structure.`;
export function buildSystemPrompt(req: GenerateRequest): string {
export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator.
Output ONE short Suno style description.
Requirements:
- A cohesive, production-ready combination of genres, instruments, BPM, mood,
and vocal style (or "instrumental, no vocals" if appropriate).
- Comma-separated keywords. No sentences, no bullet points, no labels.
- Maximum 25 words.
- Output ONLY the style description line no quotes, no commentary, no prefix,
no trailing punctuation.
Examples of the expected output (do NOT copy these verbatim):
- dark synthwave, analog pads, driving bassline, 110 BPM, male vocals
- indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM
- trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals
- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`;
export const SYSTEM_PROMPT_STYLE_CRAZY = `You are a Suno style-prompt generator with permission to break conventions.
Output ONE short Suno style description.
Requirements:
- DELIBERATELY combine genres, eras, or instruments that don't normally mix.
- The combination should still be parseable by Suno: use real instrument and
genre names, include a BPM, mention a vocal style.
- Comma-separated keywords. No sentences, no bullet points, no labels.
- Maximum 25 words.
- Output ONLY the style description line no quotes, no commentary, no prefix,
no trailing punctuation.
Examples of the expected output (do NOT copy these verbatim):
- gregorian chant trap, deep male choir, sub 808s, 140 BPM, dark reverb
- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`;
export function buildSystemPrompt(req) {
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return PARTIAL_SYSTEM_PROMPT;
return SYSTEM_PROMPT_PARTIAL;
}
export function buildUserMessage(req: GenerateRequest): string {
const lines: string[] = [];
export function buildUserMessage(req) {
const lines = [];
lines.push(`Music idea: ${req.input}`);
lines.push(`Language: ${req.language}`);
if (req.mood) lines.push(`Mood: ${req.mood}`);
if (req.style_hint) lines.push(`Style hint (user-provided): ${req.style_hint}`);
lines.push(`Vocals: ${req.vocals}`);
lines.push(`Section to generate: ${req.section}`);
@@ -101,3 +144,7 @@ export function buildUserMessage(req: GenerateRequest): string {
return lines.join('\n');
}
export function buildStylePrompt(mode) {
return mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL;
}
-32
View File
@@ -1,32 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
interface ConfigBannerProps {
message: string;
linkTo: string;
linkLabel: string;
}
export function ConfigBanner({ message, linkTo, linkLabel }: ConfigBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
<p className="flex-1">
{message}{' '}
<Link to={linkTo} className="font-semibold underline underline-offset-2 hover:text-amber-100">
{linkLabel}
</Link>
</p>
<button
type="button"
onClick={() => setDismissed(true)}
className="text-amber-200/70 hover:text-amber-100 transition-colors"
aria-label="Dismiss"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+103 -5
View File
@@ -7,10 +7,14 @@ import {
Settings as SettingsIcon,
Sparkles,
Shuffle,
Flame,
Wand2,
} from "lucide-react";
import { EqualizerIcon } from "./EqualizerIcon";
import type { InputValues, Language, Vocals } from "../lib/types";
import { formatElapsed, useElapsed } from "../lib/useElapsed";
import { randomStyle } from "../lib/llm";
import { useToast } from "../lib/toast";
export type { InputValues };
@@ -68,7 +72,11 @@ export function InputPanel({
cancelButton,
}: InputPanelProps) {
const [optionsOpen, setOptionsOpen] = useState(false);
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(
null,
);
const elapsed = useElapsed(loading);
const toast = useToast();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
@@ -98,6 +106,25 @@ export function InputPanel({
});
};
const handleStyleRandom = async (mode: "normal" | "crazy") => {
if (styleLoading) return; // already running, don't fire two in parallel
const ctrl = new AbortController();
setStyleLoading(mode);
try {
const style = await randomStyle(mode, ctrl.signal);
onChange({ ...values, style_hint: style });
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
toast.error(
err instanceof Error
? err.message
: `Could not generate a ${mode} style — please try again`,
);
} finally {
setStyleLoading((curr) => (curr === mode ? null : curr));
}
};
return (
<div className="relative">
{/* Animated gradient backdrop behind the header */}
@@ -144,10 +171,7 @@ export function InputPanel({
/>
<p className="mt-1 text-[11px] text-fg-muted">
<kbd className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
{navigator?.platform?.toLowerCase().includes("mac")
? "⌘"
: "Ctrl"}
+Enter
{isMac() ? "⌘" : "Ctrl"}+Enter
</kbd>{" "}
to generate
</p>
@@ -199,12 +223,20 @@ export function InputPanel({
)}
</div>
<StyleField
value={values.style_hint}
loading={styleLoading}
onChange={(v) => set("style_hint", v)}
onRandom={handleStyleRandom}
/>
<div>
<label
htmlFor="mood"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Mood
Mood{" "}
<span className="text-fg-muted/70 normal-case">(optional)</span>
</label>
<input
id="mood"
@@ -284,3 +316,69 @@ export function InputPanel({
</div>
);
}
interface StyleFieldProps {
value: string;
loading: null | "normal" | "crazy";
onChange: (v: string) => void;
onRandom: (mode: "normal" | "crazy") => void;
}
function StyleField({ value, loading, onChange, onRandom }: StyleFieldProps) {
return (
<div>
<label
htmlFor="style_hint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
Music style{" "}
<span className="text-fg-muted/70 normal-case">(optional)</span>
</label>
<textarea
id="style_hint"
rows={2}
placeholder="e.g. dark synthwave, analog pads, 110 BPM"
value={value}
onChange={(e) => onChange(e.target.value)}
className="textarea text-sm"
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onRandom("normal")}
disabled={loading !== null}
className="btn-secondary text-xs py-2"
title="Generate a coherent Suno-friendly style description"
>
{loading === "normal" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Wand2 className="w-3.5 h-3.5" />
)}
Surprise me
</button>
<button
type="button"
onClick={() => onRandom("crazy")}
disabled={loading !== null}
className="btn-secondary text-xs py-2"
title="Generate an unusual genre mashup that still works in Suno"
>
{loading === "crazy" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Flame className="w-3.5 h-3.5" />
)}
Go crazy
</button>
</div>
</div>
);
}
function isMac(): boolean {
if (typeof navigator === "undefined") return false;
const p = navigator.platform || "";
const ua = navigator.userAgent || "";
return /Mac|iPhone|iPad|iPod/.test(p) || /Mac OS X/.test(ua);
}
+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 };
+6 -15
View File
@@ -1,5 +1,5 @@
// Domain types shared across the app. These mirror the JSON shape produced by
// the LLM module.
// Domain types shared across the app. The server returns the same shapes
// described here, so the client can trust the wire format at the type level.
export type Language =
| "English"
@@ -40,29 +40,20 @@ export interface SongAssets {
export interface GenerateRequest {
input: string;
language: string; // 'English' or free-text from "Other"
language: string;
mood?: string;
style_hint?: string;
vocals: Vocals;
section: GenerationSection;
context?: Partial<SongAssets>;
}
export interface ConfigDisplay {
api_endpoint: string;
model_name: string;
api_key_set: boolean;
}
export interface TestConnectionResult {
success: boolean;
message: string;
}
export interface InputValues {
idea: string;
language: Language | string;
customLanguage: string;
mood: string;
style_hint: string; // new — fed by the "Surprise me" / "Go crazy" buttons
mood: string; // optional secondary field
vocals: Vocals;
}
+74 -52
View File
@@ -7,12 +7,11 @@ import {
type SectionKey,
} from "../components/ResultsPanel";
import { StickyZipBar } from "../components/StickyZipBar";
import { ConfigBanner } from "../components/ConfigBanner";
import { ThemeToggle } from "../components/ThemeToggle";
import { HistoryPanel } from "../components/HistoryPanel";
import { Footer } from "../components/Footer";
import { useToast } from "../lib/toast";
import { generateSong, getConfigDisplay } from "../lib/llm";
import { generateSong, getServerStatus } from "../lib/llm";
import { addToHistory, type HistoryEntry } from "../lib/history";
import { buildSongZip, downloadBlob, sanitizeFilename } from "../lib/zip";
import {
@@ -25,6 +24,7 @@ const DEFAULT_INPUT: InputValues = {
idea: "",
language: "English",
customLanguage: "",
style_hint: "",
mood: "",
vocals: "vocals",
};
@@ -35,7 +35,6 @@ function resolveLanguage(v: InputValues): string {
: v.language;
}
// `true` when any in-flight generation is running, false otherwise.
function isAnyBusy(loading: boolean, regenerating: RegeneratingMap): boolean {
if (loading) return true;
for (const v of Object.values(regenerating)) if (v) return true;
@@ -53,7 +52,7 @@ export function HomePage() {
const [loading, setLoading] = useState(false);
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
const [apiKeySet, setApiKeySet] = useState<boolean>(false);
const [serverOk, setServerOk] = useState<boolean | null>(null);
// AbortController for the *current* network request. Refs are used so the
// cancel handler always sees the latest value without re-binding.
@@ -61,11 +60,18 @@ export function HomePage() {
const anyRegenerating = isAnyBusy(loading, regenerating);
// Check localStorage on mount: if the API key isn't set, show a banner
// pointing the user at Settings.
// Check the server on mount. If unreachable, surface a quiet banner.
useEffect(() => {
const cfg = getConfigDisplay();
setApiKeySet(cfg.api_key_set);
const ctrl = new AbortController();
(async () => {
try {
const s = await getServerStatus(ctrl.signal);
setServerOk(Boolean(s.ok));
} catch {
setServerOk(false);
}
})();
return () => ctrl.abort();
}, []);
// Clean up any in-flight request on unmount.
@@ -83,11 +89,43 @@ export function HomePage() {
abortRef.current = null;
}, []);
// Esc cancels any in-flight generation.
useEffect(() => {
if (!anyRegenerating) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [anyRegenerating, cancel]);
// Build the request payload from the current form values. Kept as a
// function so all three call sites (full / regenerate all / per-section)
// stay in sync.
const buildRequest = useCallback(
(
section: GenerateCall["section"],
extra: Partial<GenerateCall> = {},
): GenerateCall => ({
input: input.idea.trim(),
language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
...(input.style_hint.trim()
? { style_hint: input.style_hint.trim() }
: {}),
vocals: input.vocals,
section,
...extra,
}),
[input],
);
const runGeneration = useCallback(
async (
buildRequest: (
signal: AbortSignal,
) => Promise<GenerateCall> | GenerateCall,
build: () => GenerateCall,
onSuccess: (result: Partial<SongAssets>) => void,
busySetter: (b: boolean) => void,
successMsg: string,
@@ -99,7 +137,7 @@ export function HomePage() {
busySetter(true);
try {
const req = await buildRequest(ctrl.signal);
const req = build();
const result = await generateSong(req, { signal: ctrl.signal });
onSuccess(result);
toast.success(successMsg);
@@ -124,36 +162,23 @@ export function HomePage() {
const handleGenerate = useCallback(async () => {
if (!input.idea.trim()) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all" as const,
}),
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
setOriginalAssets(next);
setSelectedTitleIndex(0);
// Persist to history. Don't `await` — the user shouldn't wait.
addToHistory(input, next);
},
setLoading,
"Song assets generated",
);
}, [input, runGeneration]);
}, [input, buildRequest, runGeneration]);
const handleRegenerateAll = useCallback(async () => {
if (!input.idea.trim()) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section: "all" as const,
}),
() => buildRequest("all"),
(result) => {
const next = result as SongAssets;
setAssets(next);
@@ -167,23 +192,16 @@ export function HomePage() {
},
"Regenerated all sections",
);
}, [input, runGeneration]);
}, [input, buildRequest, runGeneration]);
const handleRegenerateSection = useCallback(
async (section: SectionKey) => {
if (!input.idea.trim() || !assets) return;
await runGeneration(
() => ({
input: input.idea.trim(),
language: resolveLanguage(input),
mood: input.mood.trim() || undefined,
vocals: input.vocals,
section,
context: assets,
}),
() => buildRequest(section, { context: assets }),
(result) => {
// Merge partial into current assets, and update the original snapshot
// for the keys that the model returned. Any keys the user has
// Merge partial into current assets, and update the original
// snapshot for the keys that came back. Any keys the user has
// hand-edited that the model *didn't* return stay as-is.
const merged: SongAssets = { ...assets, ...result };
setAssets(merged);
@@ -193,14 +211,15 @@ export function HomePage() {
`Regenerated ${SECTION_LABELS[section]}`,
);
},
[input, assets, runGeneration],
[input, assets, buildRequest, runGeneration],
);
const handleDownload = useCallback(async () => {
if (!assets || !selectedTitle) return;
try {
const blob = await buildSongZip(assets, selectedTitle);
downloadBlob(blob, `${sanitizeFilename(selectedTitle)}.zip`);
const safe = sanitizeFilename(selectedTitle);
downloadBlob(blob, `${safe || "song"}.zip`);
} catch {
toast.error("Could not generate ZIP — please try again");
}
@@ -230,13 +249,14 @@ export function HomePage() {
// Restore a previous generation from history.
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
if (anyRegenerating) cancel();
setInput(entry.input);
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
toast.info(`Loaded "${truncate(entry.input.idea, 60)}"`);
},
[toast],
[anyRegenerating, cancel, toast],
);
return (
@@ -244,19 +264,21 @@ export function HomePage() {
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
<span className="text-sm text-fg-muted">MelodyMuse</span>
<ThemeToggle />
<div className="flex items-center gap-3">
{serverOk === false && (
<span
className="text-xs text-rose-300 hidden sm:inline"
title="The MelodyMuse server is unreachable. Generation will fail."
>
Server offline
</span>
)}
<ThemeToggle />
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
{!apiKeySet && (
<ConfigBanner
message="No API key configured —"
linkTo="/settings"
linkLabel="Go to Settings →"
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
<InputPanel
@@ -264,6 +286,7 @@ export function HomePage() {
onChange={setInput}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
cancelButton={
<button
type="button"
@@ -315,5 +338,4 @@ function truncate(s: string, n: number): string {
return t.slice(0, n - 1) + "…";
}
// Internal alias so the `runGeneration` helper stays readable.
type GenerateCall = Parameters<typeof generateSong>[0];
+178 -211
View File
@@ -2,113 +2,105 @@ import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowLeft,
Eye,
EyeOff,
CheckCircle2,
AlertTriangle,
Loader2,
Save,
Plug,
Trash2,
Server,
} from "lucide-react";
import { ThemeToggle } from "../components/ThemeToggle";
import { useToast } from "../lib/toast";
import {
getConfigDisplay,
setConfig,
previewConfig,
testConnectionWithConfig,
saveConfig,
loadConfig,
DEFAULT_MODEL,
} from "../lib/llm";
import { getServerStatus, type ServerStatus } from "../lib/llm";
const DEFAULTS = {
api_endpoint: "",
api_key: "",
model_name: DEFAULT_MODEL,
};
const STORAGE_KEY = "melodymuse-config";
const HISTORY_KEY = "melodymuse-history";
const THEME_KEY = "melodymuse-theme";
interface LocalDataSummary {
historyEntries: number;
hasTheme: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { historyEntries: 0, hasTheme: false };
}
let historyEntries = 0;
let hasTheme = false;
try {
const raw = window.localStorage.getItem(HISTORY_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) historyEntries = parsed.length;
}
} catch {
/* ignore */
}
hasTheme = window.localStorage.getItem(THEME_KEY) !== null;
return { historyEntries, hasTheme };
}
export function SettingsPage() {
const toast = useToast();
const navigate = useNavigate();
const [endpoint, setEndpoint] = useState(DEFAULTS.api_endpoint);
const [model, setModel] = useState(DEFAULTS.model_name);
const [apiKey, setApiKey] = useState(""); // never pre-populated from storage
const [apiKeySet, setApiKeySet] = useState(false);
const [showKey, setShowKey] = useState(false);
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
historyEntries: 0,
hasTheme: false,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const check = async (signal?: AbortSignal) => {
setChecking(true);
setStatusError(null);
try {
const s = await getServerStatus(signal);
setStatus(s);
} catch (err) {
setStatusError(err instanceof Error ? err.message : "Server unreachable");
setStatus(null);
} finally {
setChecking(false);
}
};
// Load current (non-secret) values on mount.
useEffect(() => {
const cfg = getConfigDisplay();
setEndpoint(cfg.api_endpoint);
setModel(cfg.model_name);
setApiKeySet(cfg.api_key_set);
setLoading(false);
const ctrl = new AbortController();
check(ctrl.signal);
setSummary(readLocalDataSummary());
return () => ctrl.abort();
}, []);
const onSave = (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const result = setConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
setApiKey("");
setApiKeySet(result.api_key_set);
toast.success("Configuration saved");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to save configuration",
);
} finally {
setSaving(false);
}
};
// Test using *in-memory* form values — don't persist until the user clicks
// Save. An empty API key field still falls back to the previously saved key,
// matching the behaviour of Save.
const onTest = async () => {
setTesting(true);
const candidate = previewConfig({
api_endpoint: endpoint.trim(),
api_key: apiKey,
model_name: model.trim(),
});
const onClearHistory = () => {
if (summary.historyEntries === 0) return;
if (
!candidate.api_endpoint ||
!candidate.api_key ||
!candidate.model_name
) {
setTesting(false);
toast.error("❌ Fill in endpoint, key, and model before testing");
!window.confirm(
`Delete all ${summary.historyEntries} recent generation${
summary.historyEntries === 1 ? "" : "s"
} from this browser? This cannot be undone.`,
)
)
return;
}
const result = await testConnectionWithConfig(candidate);
if (result.success) {
toast.success(`${result.message}`);
} else {
toast.error(`${result.message}`);
}
setTesting(false);
window.localStorage.removeItem(HISTORY_KEY);
setSummary((s) => ({ ...s, historyEntries: 0 }));
toast.info("Recent generations cleared");
};
const onClearKey = () => {
if (!apiKeySet) return;
if (!window.confirm("Clear the saved API key from this browser?")) return;
const current = loadConfig();
saveConfig({ ...current, api_key: "" });
setApiKeySet(false);
setApiKey("");
toast.info("Saved API key cleared");
const onClearAll = () => {
if (
!window.confirm(
"Clear all MelodyMuse data from this browser? This will remove the recent generations and the theme preference. The API key on the server is NOT affected.",
)
)
return;
window.localStorage.removeItem(STORAGE_KEY);
window.localStorage.removeItem(HISTORY_KEY);
window.localStorage.removeItem(THEME_KEY);
setSummary({ historyEntries: 0, hasTheme: false });
toast.info("Local data cleared");
};
return (
@@ -123,150 +115,118 @@ export function SettingsPage() {
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-sm font-semibold text-fg">API Configuration</h1>
<h1 className="text-sm font-semibold text-fg">Settings</h1>
<ThemeToggle />
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<div className="card p-6">
{loading ? (
<div className="space-y-4 animate-pulse">
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-4 w-1/3 bg-bg-hover rounded" />
<div className="h-10 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
<div className="h-12 bg-bg-hover rounded" />
</div>
) : (
<form onSubmit={onSave} className="space-y-4">
<div>
<label
htmlFor="endpoint"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Endpoint URL
</label>
<input
id="endpoint"
type="text"
className="input"
placeholder="https://api.minimax.chat/v1"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
required
/>
</div>
<div className="card p-6 space-y-6">
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
<Server className="w-4 h-4" />
Server status
</h2>
<div>
<label
htmlFor="apiKey"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
>
API Key
</label>
<div className="relative">
<input
id="apiKey"
type={showKey ? "text" : "password"}
className="input pr-10 font-mono"
placeholder={
apiKeySet
? "API key saved — enter a new one to replace it"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-fg-muted hover:text-fg transition-colors"
aria-label={showKey ? "Hide API key" : "Show API key"}
tabIndex={-1}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border bg-bg-card/40">
<div className="flex items-center gap-3 min-w-0">
<StatusDot checking={checking} ok={Boolean(status)} />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">
{checking
? "Checking…"
: status
? "Connected"
: statusError
? "Unreachable"
: "Unknown"}
</p>
{status && (
<p className="text-xs text-fg-muted truncate">
Model: <span className="font-mono">{status.model}</span>
<br />
Endpoint:{" "}
<span className="font-mono">{status.endpoint}</span>
</p>
)}
</button>
{statusError && !checking && (
<p className="text-xs text-rose-300 break-words">
{statusError}
</p>
)}
</div>
</div>
{apiKeySet && !apiKey && (
<p className="mt-1 text-xs text-emerald-400">
A key is currently saved.
</p>
)}
{apiKeySet && (
<button
type="button"
onClick={onClearKey}
className="mt-2 inline-flex items-center gap-1 text-xs text-fg-muted hover:text-rose-300 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Clear saved key
</button>
)}
</div>
<div>
<label
htmlFor="model"
className="block text-xs uppercase tracking-wider text-fg-muted mb-1"
<button
type="button"
onClick={() => check()}
disabled={checking}
className="btn-ghost"
>
Model Name
</label>
<input
id="model"
type="text"
className="input"
placeholder="MiniMax-M3"
value={model}
onChange={(e) => setModel(e.target.value)}
required
/>
{checking ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
<span>Re-check</span>
</button>
</div>
<button
type="submit"
disabled={saving || testing}
className="btn-primary w-full py-3"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Configuration
</button>
<p className="text-xs text-fg-muted">
The LLM provider's API key lives on this server in the{" "}
<code className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
LLM_API_KEY
</code>{" "}
environment variable. The browser never sees it.
</p>
</div>
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3">Local data</h2>
<p className="text-xs text-fg-muted mb-3">
MelodyMuse keeps some data in this browser. None of it leaves your
device except for the generation requests themselves.
</p>
<ul className="text-xs text-fg-muted space-y-1 mb-4">
<li>
<code className="font-mono">melodymuse-history</code> {" "}
{summary.historyEntries} recent generation
{summary.historyEntries === 1 ? "" : "s"}
</li>
<li>
<code className="font-mono">melodymuse-theme</code> {" "}
{summary.hasTheme ? "set" : "using default"}
</li>
</ul>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onTest}
disabled={saving || testing}
className="btn-secondary w-full py-3"
onClick={onClearHistory}
disabled={summary.historyEntries === 0}
className="btn-secondary"
>
{testing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Test Connection
<Trash2 className="w-4 h-4" />
Clear recent generations
</button>
</form>
)}
<button
type="button"
onClick={onClearAll}
className="btn-secondary text-rose-300 hover:text-rose-200"
>
<Trash2 className="w-4 h-4" />
Clear all local data
</button>
</div>
</section>
<p className="mt-6 text-center text-xs text-fg-muted">
Your settings are stored in this browser's local storage. Do not use
this app on a device that other people have access to.
<p className="text-center text-xs text-fg-muted">
API key on the server · Recent generations in this browser only
</p>
<div className="mt-4 text-center">
<div className="text-center">
<Link
to="/"
className="text-xs text-fg-muted hover:text-fg transition-colors"
@@ -279,3 +239,10 @@ export function SettingsPage() {
</div>
);
}
function StatusDot({ checking, ok }: { checking: boolean; ok: boolean }) {
if (checking)
return <Loader2 className="w-5 h-5 text-fg-muted animate-spin shrink-0" />;
if (ok) return <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />;
return <AlertTriangle className="w-5 h-5 text-rose-400 shrink-0" />;
}
+11 -2
View File
@@ -1,10 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// In dev, proxy /api/* to the Node server so the SPA and the API look
// same-origin to the browser. Run `npm run dev:server` in another
// terminal first.
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: false,
},
},
},
});