Compare commits

...
25 Commits
Author SHA1 Message Date
Jannik d157d34fca Optimize frontend rendering, CSS, and backend history caching 2026-07-04 23:14:47 +02:00
Jannik b888d3d3fb Auto-Commit: 2026-07-04 22:51:07 2026-07-04 22:51:07 +02:00
Jannik fa05cfded7 fix: increase max_tokens for Minimax-M3 to prevent truncated JSON 2026-06-15 19:41:51 +02:00
Jannik 5e16a7049f chore: remove hardcoded api keys from minimax test scripts 2026-06-14 11:14:39 +02:00
Jannik 1abd2d1d59 Auto-Commit: 2026-06-13 15:16:41 2026-06-13 15:16:41 +02:00
Jannik ad326c7efa chore: update .gitignore with temp files and venv 2026-06-13 14:10:18 +02:00
Jannik 6c1a8cd281 feat: add chat ai link to header 2026-06-04 20:27:41 +02:00
Jannik ade33edc6d feat: visually highlight the recommended video ai button 2026-06-04 20:20:05 +02:00
Jannik d7aa7a349a fix: tool recommendation and lyrics text selection 2026-06-04 13:48:26 +02:00
Jannik 0932357cdf fix: normalize newlines for lyrics replacement 2026-06-04 13:25:53 +02:00
Jannik cee3d24582 style: fix custom cursor on labels 2026-06-04 12:56:55 +02:00
Jannik b1b77c1714 style: make settings page wider for better prompt editing 2026-06-04 12:54:15 +02:00
Jannik cba6a173e6 chore: user tweaks to system prompts 2026-06-04 12:51:17 +02:00
Jannik 5bc3b6bbcb feat: improve video AI tool links UX and copy prompt on click 2026-06-04 12:49:01 +02:00
Jannik 8e36ec5ca5 fix: add missing hooks and fix type error 2026-06-04 11:53:00 +02:00
Jannik f4cb318175 feat: add editable system prompts from web UI 2026-06-04 11:31:56 +02:00
Jannik ecd75a7fb5 fix: dynamically suggest video tools based on env configuration 2026-06-04 11:21:23 +02:00
Jannik ab5a967fe4 feat: add configurable video AI links 2026-06-04 11:11:36 +02:00
Jannik 9dddaffdd4 fix: resolve 500 error on partial lyrics regeneration and fix mobile scrolling by removing overflow: hidden 2026-06-04 10:51:48 +02:00
Jannik 0353a5a9af fix: increase max_tokens and handle unclosed think blocks for style generation 2026-06-04 08:50:58 +02:00
Jannik 5dae92f137 UI: Change Suno hover color to orange 2026-06-04 00:52:15 +02:00
Jannik cacb11b8e7 UI: Add custom glowing I-beam text cursor 2026-06-04 00:51:37 +02:00
Jannik 4769d2a9b8 UI: Change light mode icon to a lightbulb 2026-06-04 00:46:35 +02:00
Jannik 5249d87b59 UI: Add Suno link to header 2026-06-04 00:42:10 +02:00
Jannik 478c611d05 UI: Make layout fully responsive for mobile and tablet 2026-06-04 00:40:26 +02:00
18 changed files with 861 additions and 133 deletions
+3
View File
@@ -108,3 +108,6 @@ CORS_ORIGIN=*
# 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
# Video AI Links configuration (comma-separated Key=URL pairs)
VIDEO_AI_LINKS="Kling AI=https://klingai.com,Luma=https://lumalabs.ai,Runway=https://runwayml.com"
+9
View File
@@ -33,3 +33,12 @@ lerna-debug.log*
*.njsproj
*.sln
*.sw?
# python / environments
.venv/
venv/
# temporary files
temp.txt
.tmp
scratch/
+17
View File
@@ -0,0 +1,17 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradText" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#e879f9" />
<stop offset="100%" stop-color="#06b6d4" />
</linearGradient>
<filter id="glowText" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<path d="M9 4 H15 M12 4 V20 M9 20 H15"
stroke="url(#gradText)"
stroke-width="1.5"
stroke-linecap="round"
filter="url(#glowText)"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

+103 -7
View File
@@ -26,6 +26,8 @@ import {
buildStylePrompt,
buildSystemPrompt,
buildUserMessage,
setActivePrompts,
DEFAULT_PROMPTS,
} from './server/prompts.mjs';
// ──────────────────────────────────────────────────────────────────────────────
@@ -38,6 +40,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
const HISTORY_FILE = join(DATA_DIR, 'history.json');
const PROMPTS_FILE = join(DATA_DIR, 'prompts.json');
const MAX_HISTORY = 500;
const LLM = {
@@ -58,8 +61,20 @@ if (!LLM.endpoint || !LLM.apiKey) {
// Ensure data directory exists
await mkdir(DATA_DIR, { recursive: true });
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
console.log(`[startup] LLM model: ${LLM.model}`);
// Load prompts
try {
const customPromptsStr = await readFile(PROMPTS_FILE, 'utf-8');
const customPrompts = JSON.parse(customPromptsStr);
setActivePrompts(customPrompts);
console.log('[startup] Custom system prompts loaded');
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('[startup] Failed to read prompts.json:', err.message);
}
}
console.log(`[startup] Model: ${LLM.model}`);
console.log(`[startup] Endpoint: ${LLM.endpoint}`);
console.log(`[startup] Data dir: ${DATA_DIR}`);
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
@@ -67,17 +82,22 @@ console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// History helpers
// ──────────────────────────────────────────────────────────────────────────────
let historyCache = null;
async function readHistory() {
if (historyCache !== null) return historyCache;
try {
const raw = await readFile(HISTORY_FILE, 'utf-8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
historyCache = Array.isArray(parsed) ? parsed : [];
} catch {
return [];
historyCache = [];
}
return historyCache;
}
async function writeHistory(entries) {
historyCache = entries;
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
}
@@ -210,7 +230,7 @@ async function callProvider(messages, opts = {}, signal) {
}
// Strip <think> blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1)
content = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '').trim();
content = content.replace(/<think>[\s\S]*?(?:<\/think>\s*|$)/gi, '').trim();
return content;
}
@@ -349,6 +369,24 @@ async function handleHealth(req, res) {
});
}
function handleConfig(req, res) {
let videoAiLinks = [];
const envLinks = process.env.VIDEO_AI_LINKS || '';
if (envLinks.trim()) {
// Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai"
videoAiLinks = envLinks.split(',').map(part => {
const sep = part.includes('=') ? '=' : '|';
const idx = part.indexOf(sep);
if (idx > 0) {
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
}
return null;
}).filter(link => link !== null);
}
json(res, { videoAiLinks });
}
async function handleStyleRandom(req, res) {
let body;
try { body = await readJsonBody(req); }
@@ -368,7 +406,7 @@ async function handleStyleRandom(req, res) {
{ role: 'system', content: buildStylePrompt(mode, idea) },
{ role: 'user', content: userMsg },
],
{ max_tokens: 120 },
{ max_tokens: 8192 },
signal,
);
const style = cleanStyleString(content);
@@ -462,10 +500,14 @@ async function handleGenerate(req, res) {
{ role: 'system', content: buildSystemPrompt(body) },
{ role: 'user', content: buildUserMessage(body) },
],
{ max_tokens: 4000 },
{ max_tokens: 32768 },
signal,
);
if (body.section === 'lyrics_partial') {
return json(res, { lyrics: content });
}
const parsed = extractJson(content);
if (body.section === 'all') {
@@ -491,6 +533,45 @@ async function handleGenerate(req, res) {
json(res, { error: lastErr?.message || 'Generation failed' }, 500);
}
// ──────────────────────────────────────────────────────────────────────────────
// System Prompts Config Handlers
// ──────────────────────────────────────────────────────────────────────────────
import { activePrompts } from './server/prompts.mjs';
import { unlink } from 'node:fs/promises';
function handleGetSystemPrompts(req, res) {
json(res, activePrompts);
}
async function handlePostSystemPrompts(req, res) {
try {
const body = await parseJsonBody(req);
// Merge with defaults to ensure all keys exist
const newPrompts = { ...DEFAULT_PROMPTS, ...body };
setActivePrompts(newPrompts);
await writeFile(PROMPTS_FILE, JSON.stringify(newPrompts, null, 2), 'utf-8');
json(res, { success: true });
} catch (err) {
console.error('[prompts] failed to save', err);
json(res, { error: 'Failed to save prompts' }, 500);
}
}
async function handleDeleteSystemPrompts(req, res) {
try {
try {
await unlink(PROMPTS_FILE);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
setActivePrompts(DEFAULT_PROMPTS);
json(res, { success: true });
} catch (err) {
console.error('[prompts] failed to delete', err);
json(res, { error: 'Failed to restore default prompts' }, 500);
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Server
// ──────────────────────────────────────────────────────────────────────────────
@@ -508,6 +589,21 @@ const server = createServer(async (req, res) => {
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
return handleHealth(req, res);
}
if (req.method === 'GET' && req.url.split('?')[0] === '/api/config') {
return handleConfig(req, res);
}
// System Prompts endpoints
if (req.method === 'GET' && req.url.split('?')[0] === '/api/system-prompts') {
return handleGetSystemPrompts(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/system-prompts') {
return handlePostSystemPrompts(req, res);
}
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/system-prompts') {
return handleDeleteSystemPrompts(req, res);
}
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
return handleGenerate(req, res);
}
+77 -45
View File
@@ -1,7 +1,8 @@
// 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.
export const DEFAULT_PROMPTS = {
all: `You are a music production assistant specializing in Suno AI song creation.
The user will describe a music idea. Generate all song assets as a single JSON
object. Output ONLY valid JSON no markdown, no backticks, no preamble.
@@ -32,27 +33,24 @@ REQUIRED JSON STRUCTURE:
"video_prompts": [
{
"type": "Abstract",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason why they suit this prompt"
"prompt": "short seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
},
{
"type": "Cinematic",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason"
"prompt": "short seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
},
{
"type": "Hybrid",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason"
"prompt": "short seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}
]
}
Keep each video_prompts[].prompt under 900 characters (excluding the Negative line).
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 SYSTEM_PROMPT_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
Keep each video_prompts[].prompt under 900 characters (excluding the Negative line).`,
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.
@@ -79,12 +77,11 @@ QUALITY CONSTRAINTS:
ABAB rhyme. No references to instruments, genre, or music production.
- style: comma-separated, max 120 words, include tempo + BPM range, never
name an artist, no "sounds like" phrases.
- video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters.
- video_prompts: short seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters. Recommend a tool from: {{VIDEO_TOOLS}}.
- youtube_description: follow the hook description divider lyrics
divider CTA hashtags structure.`;
export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator.
divider CTA hashtags structure.`,
style_normal: `You are a Suno style-prompt generator.
Output ONE short Suno style description.
@@ -101,9 +98,8 @@ 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.
- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`,
style_crazy: `You are a Suno style-prompt generator with permission to break conventions.
Output ONE short Suno style description.
@@ -123,9 +119,8 @@ Examples of the expected output (do NOT copy these verbatim):
- 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 const SYSTEM_PROMPT_LYRICS_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`,
lyrics_partial: `You are a music production assistant specializing in Suno AI song creation.
The user has existing lyrics and wants to regenerate a SPECIFIC selected passage only.
@@ -135,12 +130,44 @@ Rules:
- Keep the same emotional tone, language, and Suno section tags.
- The lyrics must NOT reference the genre, instruments, or music production.
- Output ONLY the rewritten passage text no JSON, no explanation, no quotes around it.
- Do NOT include section tags in your output unless the selected passage already contained them.`;
- Do NOT include section tags in your output unless the selected passage already contained them.`
};
export let activePrompts = { ...DEFAULT_PROMPTS };
export function setActivePrompts(prompts) {
activePrompts = { ...DEFAULT_PROMPTS, ...prompts };
}
function getToolsString() {
let tools = 'Runway Gen-3 Alpha, Kling AI, Haiper';
const envLinks = process.env.VIDEO_AI_LINKS || '';
if (envLinks.trim()) {
const names = envLinks.split(',').map(part => {
const idx = part.indexOf('=');
return idx > 0 ? part.slice(0, idx).trim() : null;
}).filter(Boolean);
if (names.length > 0) {
tools = names.join(', ');
}
}
return tools;
}
export function buildSystemPrompt(req) {
if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL;
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
return SYSTEM_PROMPT_PARTIAL;
const toolsStr = getToolsString();
if (req.section === 'lyrics_partial') {
return activePrompts.lyrics_partial;
}
if (req.section === 'all') {
return activePrompts.all.replace('{{VIDEO_TOOLS}}', toolsStr);
}
return activePrompts.partial.replace('{{VIDEO_TOOLS}}', toolsStr);
}
export function buildStylePrompt(crazy) {
return crazy ? activePrompts.style_crazy : activePrompts.style_normal;
}
export function buildUserMessage(req) {
@@ -156,28 +183,33 @@ export function buildUserMessage(req) {
'',
'Selected passage to rewrite:',
req.selected_text ?? '',
].filter(l => l !== null).join('\n');
]
.filter((line) => line !== null)
.join('\n');
}
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}`);
const lines = [
`Music idea: ${req.input}`,
`Language: ${req.language}`,
req.mood ? `Mood: ${req.mood}` : '',
req.vocals ? `Vocals: ${req.vocals}` : '',
];
if (req.context && Object.keys(req.context).length > 0) {
if (req.section !== 'all' && req.context) {
lines.push('');
lines.push('Context — the current values of the other sections of this song:');
lines.push(JSON.stringify(req.context, null, 2));
lines.push('CURRENT STATE OF OTHER SECTIONS:');
if (req.context.titles) {
lines.push('Titles:', ...req.context.titles.map((t) => `- ${t}`));
}
if (req.context.style) {
lines.push(`Style: ${req.context.style}`);
}
if (req.context.lyrics) {
lines.push('Lyrics:', req.context.lyrics);
}
lines.push('');
lines.push(`Please regenerate ONLY the following section: ${req.section}`);
}
return lines.join('\n');
}
export function buildStylePrompt(mode, idea) {
const base = mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL;
if (!idea || !idea.trim()) return base;
return base + `\n\nUser's music idea (use as context for tailoring the style): "${idea.trim()}"`;
return lines.filter((line) => line !== null).join('\n');
}
+11 -7
View File
@@ -1,20 +1,24 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { SettingsPage } from './pages/SettingsPage';
import { ToastProvider } from './lib/toast';
import { I18nProvider } from './lib/i18n';
import { ThemeProvider } from './lib/theme';
const HomePage = lazy(() => import('./pages/HomePage').then(module => ({ default: module.HomePage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(module => ({ default: module.SettingsPage })));
export default function App() {
return (
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<HomePage />} />
</Routes>
<Suspense fallback={<div className="h-screen w-screen flex items-center justify-center bg-bg"><div className="w-8 h-8 rounded-full border-4 border-accent-primary border-t-transparent animate-spin" /></div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<HomePage />} />
</Routes>
</Suspense>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
+35 -13
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
import { useState, useEffect, type FormEvent, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleNotch,
@@ -51,7 +51,8 @@ const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }
];
interface InputPanelProps {
values: InputValues;
initialValues: InputValues;
forcedUpdate?: { count: number; values: InputValues };
onChange: (next: InputValues) => void;
onSubmit: () => void;
loading: boolean;
@@ -60,19 +61,32 @@ interface InputPanelProps {
}
export function InputPanel({
values,
initialValues,
forcedUpdate,
onChange,
onSubmit,
loading,
disabled,
cancelButton,
}: InputPanelProps) {
const [values, setValues] = useState<InputValues>(initialValues);
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
const elapsed = useElapsed(loading);
const { t } = useI18n();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
onChange({ ...values, [key]: value });
// Handle external updates (e.g. from loading history)
useEffect(() => {
if (forcedUpdate && forcedUpdate.count > 0) {
setValues(forcedUpdate.values);
onChange(forcedUpdate.values);
}
}, [forcedUpdate, onChange]);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => {
const next = { ...values, [key]: value };
setValues(next);
onChange(next);
};
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
@@ -99,22 +113,26 @@ export function InputPanel({
setStyleLoading("normal");
try {
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({
const nextValues = {
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
};
setValues(nextValues);
onChange(nextValues);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
onChange({
const nextValues = {
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
};
setValues(nextValues);
onChange(nextValues);
} finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr));
}
@@ -131,22 +149,26 @@ export function InputPanel({
setStyleLoading("crazy");
try {
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({
const nextValues = {
...values,
idea: ideaToUse,
style_hint: style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
};
setValues(nextValues);
onChange(nextValues);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
onChange({
const nextValues = {
...values,
idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals
});
};
setValues(nextValues);
onChange(nextValues);
} finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
}
+70 -8
View File
@@ -2,13 +2,16 @@ import { useState } from "react";
import { ResultCard } from "../ResultCard";
import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faVideo, faUndo } from "@fortawesome/free-solid-svg-icons";
import { faVideo, faUndo, faStar } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight";
import {
NEGATIVE_VIDEO_PROMPT,
type VideoPrompt,
type VideoPromptType,
} from "../../lib/types";
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../../lib/toast";
interface VideoPromptsCardProps {
prompts: VideoPrompt[];
@@ -34,6 +37,37 @@ export function VideoPromptsCard({
const dirty = current?.prompt !== originalCurrent?.prompt;
const ref = useAutoHeight(current?.prompt ?? "", 120);
const { links } = useVideoAiLinks();
const toast = useToast();
const handleToolClick = async (url: string) => {
if (!current) return;
// Copy the prompt to clipboard (with negative prompt if applicable)
const textToCopy = `Prompt: ${current.prompt}\n\nNegative: ${NEGATIVE_VIDEO_PROMPT}`;
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Prompt copied to clipboard!");
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = textToCopy;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
toast.success("Prompt copied to clipboard!");
} catch {
toast.error("Failed to copy prompt");
}
document.body.removeChild(ta);
}
// Open the tool in a new tab
window.open(url, "_blank", "noopener,noreferrer");
};
return (
<ResultCard
@@ -100,13 +134,6 @@ export function VideoPromptsCard({
/>
</div>
<p className="text-sm italic text-fg-muted">
Best for:{" "}
<span className="not-italic text-fg">
{current.tool_recommendation}
</span>
</p>
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
Negative
@@ -115,6 +142,41 @@ export function VideoPromptsCard({
{NEGATIVE_VIDEO_PROMPT}
</p>
</div>
{current.tool_recommendation && (
<div className="pt-1 text-xs font-medium text-fg">
Recommended AI: <span className="font-semibold text-accent-primary">{current.tool_recommendation}</span>
</div>
)}
{links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => {
const isRecommended = Boolean(
current.tool_recommendation &&
current.tool_recommendation.toLowerCase().includes(link.name.toLowerCase())
);
return (
<button
key={i}
type="button"
onClick={() => handleToolClick(link.url)}
className={
isRecommended
? "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary text-white shadow-lg shadow-accent-primary/30 transition-all duration-150 border border-accent-primary hover:bg-accent-primary/90"
: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-bg-card text-fg-muted hover:text-fg hover:bg-bg-hover transition-all duration-150 border border-border hover:border-accent-primary/40"
}
title={isRecommended ? `Recommended! Copy prompt and open ${link.name}` : `Copy prompt and open ${link.name}`}
>
{isRecommended && <FontAwesomeIcon icon={faStar} className="w-3 h-3 text-yellow-300" />}
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className={isRecommended ? "w-3 h-3 opacity-90" : "w-3 h-3 opacity-60"} />
</button>
);
})}
</div>
)}
</div>
) : (
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
+10 -3
View File
@@ -35,9 +35,12 @@
html, body, #root {
height: 100%;
overflow: hidden;
font-family: 'Inter', system-ui, sans-serif;
cursor: url('/cursor-default.svg') 4 2, auto;
cursor: url('/cursor-default.svg') 4 2, auto !important;
}
label {
cursor: url('/cursor-default.svg') 4 2, auto !important;
}
a, button, [role="button"], select, .cursor-pointer {
@@ -45,12 +48,13 @@
}
input, textarea {
cursor: text;
cursor: url('/cursor-text.svg') 12 12, text;
}
body {
@apply bg-bg text-fg antialiased transition-colors duration-300;
margin: 0;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -188,18 +192,21 @@
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-1 18s ease-in-out infinite;
}
.liquid-blob-2 {
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-2 22s ease-in-out infinite;
}
.liquid-blob-3 {
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-3 26s ease-in-out infinite;
}
+12
View File
@@ -71,6 +71,18 @@ export async function getServerStatus(
return (await resp.json()) as ServerStatus;
}
export interface ConfigResponse {
videoAiLinks: import("./types").VideoAiLink[];
}
export async function getConfig(signal?: AbortSignal): Promise<ConfigResponse> {
const resp = await fetch(`${API_BASE}/api/config`, { signal });
if (!resp.ok) {
throw new Error(`Server config check failed (${resp.status} ${resp.statusText})`);
}
return (await resp.json()) as ConfigResponse;
}
export interface GenerateOptions {
signal?: AbortSignal;
}
+6
View File
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
video_prompts: "video prompts",
youtube_description: "description",
};
export interface VideoAiLink {
name: string;
url: string;
}
+71
View File
@@ -0,0 +1,71 @@
import { useState, useEffect, useCallback } from "react";
export type SystemPrompts = {
all: string;
partial: string;
style_normal: string;
style_crazy: string;
lyrics_partial: string;
};
export function useSystemPrompts() {
const [prompts, setPrompts] = useState<SystemPrompts | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPrompts = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts");
if (!res.ok) throw new Error("Failed to fetch system prompts");
const data = await res.json();
setPrompts(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}, []);
const savePrompts = async (newPrompts: SystemPrompts) => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPrompts),
});
if (!res.ok) throw new Error("Failed to save system prompts");
setPrompts(newPrompts);
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
return false;
} finally {
setLoading(false);
}
};
const resetPrompts = async () => {
try {
setLoading(true);
const res = await fetch("/api/system-prompts", { method: "DELETE" });
if (!res.ok) throw new Error("Failed to reset system prompts");
await fetchPrompts();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
return false;
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPrompts();
}, [fetchPrompts]);
return { prompts, loading, error, savePrompts, resetPrompts };
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { getConfig } from "./llm";
import type { VideoAiLink } from "./types";
const LOCAL_STORAGE_KEY = "melodymuse-video-links";
export function useVideoAiLinks() {
const [links, setLinks] = useState<VideoAiLink[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
async function fetchLinks() {
try {
const local = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (local) {
const parsed = JSON.parse(local);
if (Array.isArray(parsed)) {
if (active) {
setLinks(parsed);
setLoading(false);
}
return;
}
}
// Fetch from backend
const res = await getConfig();
if (active) {
setLinks(res.videoAiLinks || []);
setLoading(false);
}
} catch (err) {
if (active) {
setLoading(false);
}
}
}
fetchLinks();
return () => { active = false; };
}, []);
const saveLinks = (newLinks: VideoAiLink[]) => {
setLinks(newLinks);
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newLinks));
};
const clearLocalLinks = () => {
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
// Refresh to fetch defaults again
setLoading(true);
getConfig().then(res => {
setLinks(res.videoAiLinks || []);
setLoading(false);
}).catch(() => setLoading(false));
};
return { links, loading, saveLinks, clearLocalLinks, hasLocal: window.localStorage.getItem(LOCAL_STORAGE_KEY) !== null };
}
+84 -48
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faSun, faMoon, faHistory, faCog } from "@fortawesome/free-solid-svg-icons";
import { faLightbulb, faMoon, faHistory, faCog, faRobot } from "@fortawesome/free-solid-svg-icons";
import { Link } from "react-router-dom";
import { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel";
@@ -26,7 +26,6 @@ import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
const DRAFT_KEY = "melodymuse-draft";
const DRAFT_DEBOUNCE_MS = 400;
const DEFAULT_INPUT: InputValues = {
idea: "",
@@ -103,9 +102,8 @@ export function HomePage() {
const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme();
const [input, setInput] = useState<InputValues>(
() => loadDraft() ?? DEFAULT_INPUT,
);
const inputRef = useRef<InputValues>(loadDraft() ?? DEFAULT_INPUT);
const [forcedUpdate, setForcedUpdate] = useState<{ count: number; values: InputValues }>();
const [assets, setAssets] = useState<SongAssets | null>(null);
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false);
@@ -119,14 +117,14 @@ export function HomePage() {
const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating);
// Save draft
// Save draft periodically
useEffect(() => {
const id = window.setTimeout(() => {
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); }
const id = window.setInterval(() => {
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(inputRef.current)); }
catch { /* ignore */ }
}, DRAFT_DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [input]);
}, 1000);
return () => window.clearInterval(id);
}, []);
// Server health check
useEffect(() => {
@@ -168,16 +166,19 @@ export function HomePage() {
}, [anyRegenerating, cancel]);
const buildRequest = useCallback(
(section: string, extra: Record<string, unknown> = {}): 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,
} as unknown as GenerateCall),
[input],
(section: string, extra: Record<string, unknown> = {}): GenerateCall => {
const input = inputRef.current;
return {
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,
} as unknown as GenerateCall;
},
[],
);
const runGeneration = useCallback(
@@ -212,6 +213,7 @@ export function HomePage() {
);
const handleGenerate = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
@@ -228,9 +230,10 @@ export function HomePage() {
setLoading,
t.assetsGenerated,
);
}, [input, buildRequest, runGeneration, t]);
}, [buildRequest, runGeneration, t]);
const handleRegenerateAll = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return;
setAssets(null);
setOriginalAssets(null);
@@ -250,10 +253,11 @@ export function HomePage() {
},
t.regeneratedAll,
);
}, [input, buildRequest, runGeneration, t]);
}, [buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback(
async (section: SectionKey, selectedText?: string) => {
const input = inputRef.current;
if (!input.idea.trim() || !assets) return;
const isPartialLyrics = section === "lyrics" && !!selectedText;
@@ -269,8 +273,18 @@ export function HomePage() {
let merged: SongAssets;
if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics
const newPartial = (result as any).lyrics || (result as any).text || String(result);
const replaced = assets.lyrics.replace(selectedText, newPartial);
let newPartial = (result as any).lyrics || (result as any).text || String(result);
// Cleanup quotes or markdown if the LLM leaked them
newPartial = newPartial.replace(/^```[a-z]*\n/gi, '').replace(/\n```$/g, '').trim();
if (newPartial.startsWith('"') && newPartial.endsWith('"')) {
newPartial = newPartial.slice(1, -1).trim();
}
// Normalize newlines to prevent replace() from failing due to \r\n vs \n
const normalizedAssetsLyrics = assets.lyrics.replace(/\r\n/g, '\n');
const normalizedSelected = selectedText.replace(/\r\n/g, '\n');
const replaced = normalizedAssetsLyrics.replace(normalizedSelected, newPartial);
merged = { ...assets, lyrics: replaced };
} else {
merged = { ...assets, ...result };
@@ -289,7 +303,7 @@ export function HomePage() {
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
);
},
[input, assets, buildRequest, runGeneration, t],
[assets, buildRequest, runGeneration, t],
);
// Execute chained regeneration
@@ -335,7 +349,8 @@ export function HomePage() {
const handleLoadHistory = useCallback(
(entry: HistoryEntry) => {
if (anyRegenerating) cancel();
setInput(entry.input);
inputRef.current = entry.input;
setForcedUpdate((prev) => ({ count: (prev?.count ?? 0) + 1, values: entry.input }));
setAssets(entry.assets);
setOriginalAssets(entry.assets);
setSelectedTitleIndex(0);
@@ -346,14 +361,14 @@ export function HomePage() {
);
return (
<div className="flex flex-col h-screen overflow-hidden">
<div className="flex flex-col min-h-[100dvh] md:h-screen overflow-y-auto md:overflow-hidden">
{/* Liquid background blobs */}
<div className="liquid-blob-1" />
<div className="liquid-blob-2" />
<div className="liquid-blob-3" />
{/* Header */}
<header className="shrink-0 z-20 bg-bg-card/40 backdrop-blur-md border-b border-border shadow-sm">
<header className="shrink-0 z-30 sticky top-0 bg-bg-card/70 backdrop-blur-md border-b border-border shadow-sm">
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 h-12 flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-2.5">
@@ -365,16 +380,36 @@ export function HomePage() {
{/* Right controls */}
<div className="flex items-center gap-4">
{/* Social links — icon only */}
<a
href="https://www.youtube.com/@AIWentNonsense"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-rose-500 hover:bg-rose-500/10 transition-all duration-150"
title="AI Went Nonsense on YouTube"
>
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
</a>
{/* External Links */}
<div className="flex items-center gap-1">
<a
href="https://suno.com/create"
target="_blank"
rel="noopener noreferrer"
className="px-2 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-orange-500 hover:bg-orange-500/10 transition-all duration-150 font-bold text-[11px] tracking-wide uppercase"
title="Create on Suno"
>
Suno
</a>
<a
href="https://chat.orfel.de/c/new"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-blue-400 hover:bg-blue-400/10 transition-all duration-150"
title="Open Chat AI"
>
<FontAwesomeIcon icon={faRobot} className="w-4 h-4" />
</a>
<a
href="https://www.youtube.com/@AIWentNonsense"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-rose-500 hover:bg-rose-500/10 transition-all duration-150"
title="AI Went Nonsense on YouTube"
>
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
</a>
</div>
{/* Server offline indicator */}
{serverOk === false && (
@@ -439,7 +474,7 @@ export function HomePage() {
<div className={`w-full h-full rounded-full ${theme === "dark" ? "gradient-bg opacity-10" : "opacity-0"} transition-opacity duration-300`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faSun} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-fg-muted/60" : "text-yellow-500"}`} />
<FontAwesomeIcon icon={faLightbulb} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-fg-muted/60" : "text-yellow-500"}`} />
</div>
<div className="w-5 h-5 flex items-center justify-center z-10">
<FontAwesomeIcon icon={faMoon} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-accent-primary" : "text-fg-muted/60"}`} />
@@ -450,14 +485,15 @@ export function HomePage() {
</header>
{/* Main content: 1/3 left input + 2/3 right results */}
<div className="flex-1 min-h-0 flex overflow-hidden max-w-screen-2xl mx-auto w-full px-4 sm:px-6 py-4 gap-5">
<div className="flex-1 min-h-0 flex flex-col md:flex-row md:overflow-hidden max-w-screen-2xl mx-auto w-full px-4 sm:px-6 py-4 gap-5">
{/* LEFT PANEL — 1/3 — Input */}
<div className="w-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col flex-1 min-h-0">
<div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0">
<InputPanel
values={input}
onChange={setInput}
initialValues={inputRef.current}
forcedUpdate={forcedUpdate}
onChange={(next) => { inputRef.current = next; }}
onSubmit={handleGenerate}
loading={loading}
disabled={anyRegenerating}
@@ -475,8 +511,8 @@ export function HomePage() {
</div>
{/* RIGHT PANEL — 2/3 — Results */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto scrollbar-thin px-1">
<div className="flex-1 min-w-0 flex flex-col md:overflow-hidden gap-4 md:gap-0 pb-4 md:pb-0">
<div className="flex-1 md:overflow-y-auto md:scrollbar-thin px-1">
<ResultsPanel
assets={assets}
originalAssets={originalAssets}
@@ -496,7 +532,7 @@ export function HomePage() {
{/* Download bar inside results column, always visible */}
{assets && (
<div className="shrink-0 pt-2">
<div className="shrink-0 pt-2 sticky bottom-4 md:static z-20">
<StickyZipBar
title={selectedTitle}
busy={loading}
+220 -2
View File
@@ -9,11 +9,16 @@ import {
faPlug,
faTrashAlt,
faServer,
faVideo,
faTerminal,
} from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n";
import { useVideoAiLinks } from "../lib/useVideoAiLinks";
import { useSystemPrompts, type SystemPrompts } from "../lib/useSystemPrompts";
import type { VideoAiLink } from "../lib/types";
const DRAFT_KEY = "melodymuse-draft";
@@ -41,6 +46,25 @@ export function SettingsPage() {
hasDraft: false,
});
const { links, loading: linksLoading, saveLinks, clearLocalLinks, hasLocal } = useVideoAiLinks();
const [editingLinks, setEditingLinks] = useState<VideoAiLink[] | null>(null);
const { prompts, loading: promptsLoading, savePrompts, resetPrompts } = useSystemPrompts();
const [editingPrompts, setEditingPrompts] = useState<SystemPrompts | null>(null);
const [activePromptTab, setActivePromptTab] = useState<keyof SystemPrompts>("all");
useEffect(() => {
if (!linksLoading && editingLinks === null) {
setEditingLinks(links);
}
}, [links, linksLoading, editingLinks]);
useEffect(() => {
if (!promptsLoading && editingPrompts === null && prompts) {
setEditingPrompts(prompts);
}
}, [prompts, promptsLoading, editingPrompts]);
const check = async (signal?: AbortSignal) => {
setChecking(true);
setStatusError(null);
@@ -76,10 +100,70 @@ export function SettingsPage() {
toast.info(t.clearDraft);
};
const handleLinkChange = (index: number, field: keyof VideoAiLink, value: string) => {
if (!editingLinks) return;
const next = [...editingLinks];
next[index] = { ...next[index], [field]: value };
setEditingLinks(next);
};
const handleAddLink = () => {
if (!editingLinks) return;
setEditingLinks([...editingLinks, { name: "", url: "" }]);
};
const handleRemoveLink = (index: number) => {
if (!editingLinks) return;
const next = [...editingLinks];
next.splice(index, 1);
setEditingLinks(next);
};
const handleSaveLinks = () => {
if (!editingLinks) return;
const validLinks = editingLinks.filter(l => l.name.trim() && l.url.trim());
saveLinks(validLinks);
setEditingLinks(validLinks);
toast.success("Video AI links saved to browser storage");
};
const handleResetLinks = () => {
if (!window.confirm("Restore default Video AI links from the server?")) return;
clearLocalLinks();
setEditingLinks(null); // will re-sync with hook
toast.info("Restored default links");
};
const handlePromptChange = (val: string) => {
if (!editingPrompts) return;
setEditingPrompts({ ...editingPrompts, [activePromptTab]: val });
};
const handleSavePrompts = async () => {
if (!editingPrompts) return;
const ok = await savePrompts(editingPrompts);
if (ok) {
toast.success("System Prompts saved to server");
} else {
toast.error("Failed to save prompts");
}
};
const handleResetPrompts = async () => {
if (!window.confirm("Restore default System Prompts? This will overwrite your changes.")) return;
const ok = await resetPrompts();
if (ok) {
setEditingPrompts(null); // Will re-sync with hook
toast.info("Restored default system prompts");
} else {
toast.error("Failed to restore prompts");
}
};
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center gap-2">
<div className="max-w-3xl mx-auto px-4 h-14 flex items-center gap-2">
<button
type="button"
onClick={() => navigate("/")}
@@ -92,7 +176,7 @@ export function SettingsPage() {
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<main className="max-w-3xl mx-auto px-4 mt-16">
<div className="card p-6 space-y-6">
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
@@ -177,6 +261,140 @@ export function SettingsPage() {
</div>
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faVideo} className="w-4 h-4 text-accent-secondary" />
Video AI Links
</span>
{hasLocal && (
<button
type="button"
onClick={handleResetLinks}
className="text-[10px] uppercase font-bold text-fg-muted hover:text-fg transition-colors"
>
Reset to Server Defaults
</button>
)}
</h2>
<p className="text-xs text-fg-muted mb-4">
Configure the quick links shown at the bottom of the Video Prompts card.
{hasLocal ? " Currently using your local browser overrides." : " Currently using server defaults."}
</p>
{linksLoading || editingLinks === null ? (
<div className="flex items-center justify-center py-4">
<FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-5 h-5" />
</div>
) : (
<div className="space-y-3">
{editingLinks.map((link, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
className="input py-1.5 text-xs flex-1"
placeholder="Name (e.g. Kling AI)"
value={link.name}
onChange={e => handleLinkChange(i, "name", e.target.value)}
/>
<input
type="url"
className="input py-1.5 text-xs flex-[2]"
placeholder="https://..."
value={link.url}
onChange={e => handleLinkChange(i, "url", e.target.value)}
/>
<button
type="button"
onClick={() => handleRemoveLink(i)}
className="p-1.5 rounded-lg text-rose-400 hover:bg-rose-400/10 transition-colors"
title="Remove"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={handleAddLink}
className="btn-secondary flex-1"
>
+ Add Link
</button>
<button
type="button"
onClick={handleSaveLinks}
className="btn-primary flex-1 py-1 text-sm font-semibold"
>
Save Links
</button>
</div>
</div>
)}
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center justify-between">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faTerminal} className="w-4 h-4 text-accent-primary" />
System Prompts
</span>
<button
type="button"
onClick={handleResetPrompts}
className="text-[10px] uppercase font-bold text-fg-muted hover:text-fg transition-colors"
>
Restore Defaults
</button>
</h2>
<p className="text-xs text-fg-muted mb-4">
Edit the prompts used by the LLM. Use <code className="font-mono text-accent-secondary">{"{{VIDEO_TOOLS}}"}</code> as a placeholder for where the Video AI links should be injected in the <code className="font-mono">all</code> and <code className="font-mono">partial</code> prompts.
</p>
{promptsLoading || editingPrompts === null ? (
<div className="flex items-center justify-center py-4">
<FontAwesomeIcon icon={faCircleNotch} spin className="text-fg-muted w-5 h-5" />
</div>
) : (
<div className="space-y-3">
<select
value={activePromptTab as string}
onChange={(e) => setActivePromptTab(e.target.value as keyof SystemPrompts)}
className="input py-1.5 text-xs w-full"
>
<option value="all">Full Generation (all)</option>
<option value="partial">Partial Generation (partial)</option>
<option value="style_normal">Style: Normal (style_normal)</option>
<option value="style_crazy">Style: Crazy (style_crazy)</option>
<option value="lyrics_partial">Lyrics Rewrite (lyrics_partial)</option>
</select>
<textarea
className="input w-full min-h-[500px] text-xs font-mono p-3 leading-relaxed"
value={editingPrompts[activePromptTab]}
onChange={(e) => handlePromptChange(e.target.value)}
spellCheck={false}
/>
<div className="flex justify-end pt-1">
<button
type="button"
onClick={handleSavePrompts}
className="btn-primary py-1 text-sm font-semibold px-6"
>
Save Prompts
</button>
</div>
</div>
)}
</section>
<div className="text-center pt-2">
<Link
to="/"
BIN
View File
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: 'You are a Suno style-prompt generator.' },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);
+46
View File
@@ -0,0 +1,46 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: `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.
- If the user provides a music idea, use it as a jumping-off point for an
unexpected, surprising twist on that idea.
- 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` },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);