Compare commits
23
Commits
5249d87b59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d157d34fca | ||
|
|
b888d3d3fb | ||
|
|
fa05cfded7 | ||
|
|
5e16a7049f | ||
|
|
1abd2d1d59 | ||
|
|
ad326c7efa | ||
|
|
6c1a8cd281 | ||
|
|
ade33edc6d | ||
|
|
d7aa7a349a | ||
|
|
0932357cdf | ||
|
|
cee3d24582 | ||
|
|
b1b77c1714 | ||
|
|
cba6a173e6 | ||
|
|
5bc3b6bbcb | ||
|
|
8e36ec5ca5 | ||
|
|
f4cb318175 | ||
|
|
ecd75a7fb5 | ||
|
|
ab5a967fe4 | ||
|
|
9dddaffdd4 | ||
|
|
0353a5a9af | ||
|
|
5dae92f137 | ||
|
|
cacb11b8e7 | ||
|
|
4769d2a9b8 |
@@ -108,3 +108,6 @@ CORS_ORIGIN=*
|
|||||||
# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want
|
# 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.
|
# the browser to talk to a server on a different origin.
|
||||||
# VITE_API_BASE_URL=http://localhost:3000
|
# 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"
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ lerna-debug.log*
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# python / environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
temp.txt
|
||||||
|
.tmp
|
||||||
|
scratch/
|
||||||
|
|||||||
@@ -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
@@ -26,6 +26,8 @@ import {
|
|||||||
buildStylePrompt,
|
buildStylePrompt,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
buildUserMessage,
|
buildUserMessage,
|
||||||
|
setActivePrompts,
|
||||||
|
DEFAULT_PROMPTS,
|
||||||
} from './server/prompts.mjs';
|
} from './server/prompts.mjs';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -38,6 +40,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
|
|||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
|
||||||
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
|
||||||
const HISTORY_FILE = join(DATA_DIR, 'history.json');
|
const HISTORY_FILE = join(DATA_DIR, 'history.json');
|
||||||
|
const PROMPTS_FILE = join(DATA_DIR, 'prompts.json');
|
||||||
const MAX_HISTORY = 500;
|
const MAX_HISTORY = 500;
|
||||||
|
|
||||||
const LLM = {
|
const LLM = {
|
||||||
@@ -58,8 +61,20 @@ if (!LLM.endpoint || !LLM.apiKey) {
|
|||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
await mkdir(DATA_DIR, { recursive: true });
|
await mkdir(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
console.log(`[startup] LLM endpoint: ${LLM.endpoint}`);
|
// Load prompts
|
||||||
console.log(`[startup] LLM model: ${LLM.model}`);
|
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] Data dir: ${DATA_DIR}`);
|
||||||
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
|
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
|
// History helpers
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let historyCache = null;
|
||||||
|
|
||||||
async function readHistory() {
|
async function readHistory() {
|
||||||
|
if (historyCache !== null) return historyCache;
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(HISTORY_FILE, 'utf-8');
|
const raw = await readFile(HISTORY_FILE, 'utf-8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
historyCache = Array.isArray(parsed) ? parsed : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
historyCache = [];
|
||||||
}
|
}
|
||||||
|
return historyCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeHistory(entries) {
|
async function writeHistory(entries) {
|
||||||
|
historyCache = entries;
|
||||||
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
|
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)
|
// 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;
|
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) {
|
async function handleStyleRandom(req, res) {
|
||||||
let body;
|
let body;
|
||||||
try { body = await readJsonBody(req); }
|
try { body = await readJsonBody(req); }
|
||||||
@@ -368,7 +406,7 @@ async function handleStyleRandom(req, res) {
|
|||||||
{ role: 'system', content: buildStylePrompt(mode, idea) },
|
{ role: 'system', content: buildStylePrompt(mode, idea) },
|
||||||
{ role: 'user', content: userMsg },
|
{ role: 'user', content: userMsg },
|
||||||
],
|
],
|
||||||
{ max_tokens: 120 },
|
{ max_tokens: 8192 },
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
const style = cleanStyleString(content);
|
const style = cleanStyleString(content);
|
||||||
@@ -462,10 +500,14 @@ async function handleGenerate(req, res) {
|
|||||||
{ role: 'system', content: buildSystemPrompt(body) },
|
{ role: 'system', content: buildSystemPrompt(body) },
|
||||||
{ role: 'user', content: buildUserMessage(body) },
|
{ role: 'user', content: buildUserMessage(body) },
|
||||||
],
|
],
|
||||||
{ max_tokens: 4000 },
|
{ max_tokens: 32768 },
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (body.section === 'lyrics_partial') {
|
||||||
|
return json(res, { lyrics: content });
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = extractJson(content);
|
const parsed = extractJson(content);
|
||||||
|
|
||||||
if (body.section === 'all') {
|
if (body.section === 'all') {
|
||||||
@@ -491,6 +533,45 @@ async function handleGenerate(req, res) {
|
|||||||
json(res, { error: lastErr?.message || 'Generation failed' }, 500);
|
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
|
// Server
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -508,6 +589,21 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
||||||
return handleHealth(req, res);
|
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') {
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
|
||||||
return handleGenerate(req, res);
|
return handleGenerate(req, res);
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-45
@@ -1,7 +1,8 @@
|
|||||||
// System + user prompt construction for the LLM. Server-side only — the
|
// System + user prompt construction for the LLM. Server-side only — the
|
||||||
// browser never sees these prompts.
|
// 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
|
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.
|
object. Output ONLY valid JSON — no markdown, no backticks, no preamble.
|
||||||
@@ -32,27 +33,24 @@ REQUIRED JSON STRUCTURE:
|
|||||||
"video_prompts": [
|
"video_prompts": [
|
||||||
{
|
{
|
||||||
"type": "Abstract",
|
"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",
|
"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": "Tool name(s) + one-sentence reason why they suit this prompt"
|
"tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Cinematic",
|
"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",
|
"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": "Tool name(s) + one-sentence reason"
|
"tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "Hybrid",
|
"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",
|
"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": "Tool name(s) + one-sentence reason"
|
"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).
|
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,
|
partial: `You are a music production assistant specializing in Suno AI song creation.
|
||||||
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.
|
|
||||||
|
|
||||||
The user has an existing song and wants to regenerate ONE section. Output ONLY
|
The user has an existing song and wants to regenerate ONE section. Output ONLY
|
||||||
valid JSON — no markdown, no backticks, no preamble.
|
valid JSON — no markdown, no backticks, no preamble.
|
||||||
@@ -79,12 +77,11 @@ QUALITY CONSTRAINTS:
|
|||||||
ABAB rhyme. No references to instruments, genre, or music production.
|
ABAB rhyme. No references to instruments, genre, or music production.
|
||||||
- style: comma-separated, max 120 words, include tempo + BPM range, never
|
- style: comma-separated, max 120 words, include tempo + BPM range, never
|
||||||
name an artist, no "sounds like" phrases.
|
name an artist, no "sounds like" phrases.
|
||||||
- video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line
|
- video_prompts: short seamless loops, 16:9. Include the "Negative" line
|
||||||
inside each prompt. Keep prompts under 900 characters.
|
inside each prompt. Keep prompts under 900 characters. Recommend a tool from: {{VIDEO_TOOLS}}.
|
||||||
- youtube_description: follow the hook → description → divider → lyrics →
|
- youtube_description: follow the hook → description → divider → lyrics →
|
||||||
divider → CTA → hashtags structure.`;
|
divider → CTA → hashtags structure.`,
|
||||||
|
style_normal: `You are a Suno style-prompt generator.
|
||||||
export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator.
|
|
||||||
|
|
||||||
Output ONE short Suno style description.
|
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
|
- dark synthwave, analog pads, driving bassline, 110 BPM, male vocals
|
||||||
- indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM
|
- indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM
|
||||||
- trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals
|
- trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals
|
||||||
- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`;
|
- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`,
|
||||||
|
style_crazy: `You are a Suno style-prompt generator with permission to break conventions.
|
||||||
export const SYSTEM_PROMPT_STYLE_CRAZY = `You are a Suno style-prompt generator with permission to break conventions.
|
|
||||||
|
|
||||||
Output ONE short Suno style description.
|
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
|
- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM
|
||||||
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
|
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
|
||||||
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
|
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
|
||||||
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`;
|
- 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.
|
||||||
export const SYSTEM_PROMPT_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.
|
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.
|
- Keep the same emotional tone, language, and Suno section tags.
|
||||||
- The lyrics must NOT reference the genre, instruments, or music production.
|
- 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.
|
- 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) {
|
export function buildSystemPrompt(req) {
|
||||||
if (req.section === 'lyrics_partial') return SYSTEM_PROMPT_LYRICS_PARTIAL;
|
const toolsStr = getToolsString();
|
||||||
if (req.section === 'all') return SYSTEM_PROMPT_ALL;
|
|
||||||
return SYSTEM_PROMPT_PARTIAL;
|
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) {
|
export function buildUserMessage(req) {
|
||||||
@@ -156,28 +183,33 @@ export function buildUserMessage(req) {
|
|||||||
'',
|
'',
|
||||||
'Selected passage to rewrite:',
|
'Selected passage to rewrite:',
|
||||||
req.selected_text ?? '',
|
req.selected_text ?? '',
|
||||||
].filter(l => l !== null).join('\n');
|
]
|
||||||
|
.filter((line) => line !== null)
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [];
|
const lines = [
|
||||||
lines.push(`Music idea: ${req.input}`);
|
`Music idea: ${req.input}`,
|
||||||
lines.push(`Language: ${req.language}`);
|
`Language: ${req.language}`,
|
||||||
if (req.mood) lines.push(`Mood: ${req.mood}`);
|
req.mood ? `Mood: ${req.mood}` : '',
|
||||||
if (req.style_hint) lines.push(`Style hint (user-provided): ${req.style_hint}`);
|
req.vocals ? `Vocals: ${req.vocals}` : '',
|
||||||
lines.push(`Vocals: ${req.vocals}`);
|
];
|
||||||
lines.push(`Section to generate: ${req.section}`);
|
|
||||||
|
|
||||||
if (req.context && Object.keys(req.context).length > 0) {
|
if (req.section !== 'all' && req.context) {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('Context — the current values of the other sections of this song:');
|
lines.push('CURRENT STATE OF OTHER SECTIONS:');
|
||||||
lines.push(JSON.stringify(req.context, null, 2));
|
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');
|
return lines.filter((line) => line !== null).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()}"`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -1,20 +1,24 @@
|
|||||||
|
import { Suspense, lazy } from 'react';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import { HomePage } from './pages/HomePage';
|
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
|
||||||
import { ToastProvider } from './lib/toast';
|
import { ToastProvider } from './lib/toast';
|
||||||
import { I18nProvider } from './lib/i18n';
|
import { I18nProvider } from './lib/i18n';
|
||||||
import { ThemeProvider } from './lib/theme';
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<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>}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Routes>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="*" element={<HomePage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
<Route path="*" element={<HomePage />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faCircleNotch,
|
faCircleNotch,
|
||||||
@@ -51,7 +51,8 @@ const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface InputPanelProps {
|
interface InputPanelProps {
|
||||||
values: InputValues;
|
initialValues: InputValues;
|
||||||
|
forcedUpdate?: { count: number; values: InputValues };
|
||||||
onChange: (next: InputValues) => void;
|
onChange: (next: InputValues) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -60,19 +61,32 @@ interface InputPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InputPanel({
|
export function InputPanel({
|
||||||
values,
|
initialValues,
|
||||||
|
forcedUpdate,
|
||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
cancelButton,
|
cancelButton,
|
||||||
}: InputPanelProps) {
|
}: InputPanelProps) {
|
||||||
|
const [values, setValues] = useState<InputValues>(initialValues);
|
||||||
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
|
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
|
||||||
const elapsed = useElapsed(loading);
|
const elapsed = useElapsed(loading);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) =>
|
// Handle external updates (e.g. from loading history)
|
||||||
onChange({ ...values, [key]: value });
|
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;
|
const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
|
||||||
|
|
||||||
@@ -99,22 +113,26 @@ export function InputPanel({
|
|||||||
setStyleLoading("normal");
|
setStyleLoading("normal");
|
||||||
try {
|
try {
|
||||||
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
|
const style = await randomStyle("normal", ctrl.signal, ideaToUse);
|
||||||
onChange({
|
const nextValues = {
|
||||||
...values,
|
...values,
|
||||||
idea: ideaToUse,
|
idea: ideaToUse,
|
||||||
style_hint: style,
|
style_hint: style,
|
||||||
mood: hasIdea ? values.mood : ex.mood,
|
mood: hasIdea ? values.mood : ex.mood,
|
||||||
vocals: hasIdea ? values.vocals : ex.vocals
|
vocals: hasIdea ? values.vocals : ex.vocals
|
||||||
});
|
};
|
||||||
|
setValues(nextValues);
|
||||||
|
onChange(nextValues);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
onChange({
|
const nextValues = {
|
||||||
...values,
|
...values,
|
||||||
idea: ideaToUse,
|
idea: ideaToUse,
|
||||||
style_hint: hasIdea ? values.style_hint : ex.style,
|
style_hint: hasIdea ? values.style_hint : ex.style,
|
||||||
mood: hasIdea ? values.mood : ex.mood,
|
mood: hasIdea ? values.mood : ex.mood,
|
||||||
vocals: hasIdea ? values.vocals : ex.vocals
|
vocals: hasIdea ? values.vocals : ex.vocals
|
||||||
});
|
};
|
||||||
|
setValues(nextValues);
|
||||||
|
onChange(nextValues);
|
||||||
} finally {
|
} finally {
|
||||||
setStyleLoading((curr) => (curr === "normal" ? null : curr));
|
setStyleLoading((curr) => (curr === "normal" ? null : curr));
|
||||||
}
|
}
|
||||||
@@ -131,22 +149,26 @@ export function InputPanel({
|
|||||||
setStyleLoading("crazy");
|
setStyleLoading("crazy");
|
||||||
try {
|
try {
|
||||||
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
|
const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
|
||||||
onChange({
|
const nextValues = {
|
||||||
...values,
|
...values,
|
||||||
idea: ideaToUse,
|
idea: ideaToUse,
|
||||||
style_hint: style,
|
style_hint: style,
|
||||||
mood: hasIdea ? values.mood : ex.mood,
|
mood: hasIdea ? values.mood : ex.mood,
|
||||||
vocals: hasIdea ? values.vocals : ex.vocals
|
vocals: hasIdea ? values.vocals : ex.vocals
|
||||||
});
|
};
|
||||||
|
setValues(nextValues);
|
||||||
|
onChange(nextValues);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
onChange({
|
const nextValues = {
|
||||||
...values,
|
...values,
|
||||||
idea: ideaToUse,
|
idea: ideaToUse,
|
||||||
style_hint: hasIdea ? values.style_hint : ex.style,
|
style_hint: hasIdea ? values.style_hint : ex.style,
|
||||||
mood: hasIdea ? values.mood : ex.mood,
|
mood: hasIdea ? values.mood : ex.mood,
|
||||||
vocals: hasIdea ? values.vocals : ex.vocals
|
vocals: hasIdea ? values.vocals : ex.vocals
|
||||||
});
|
};
|
||||||
|
setValues(nextValues);
|
||||||
|
onChange(nextValues);
|
||||||
} finally {
|
} finally {
|
||||||
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
|
setStyleLoading((curr) => (curr === "crazy" ? null : curr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { useState } from "react";
|
|||||||
import { ResultCard } from "../ResultCard";
|
import { ResultCard } from "../ResultCard";
|
||||||
import { CopyButton } from "../CopyButton";
|
import { CopyButton } from "../CopyButton";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { useAutoHeight } from "../../lib/useAutoHeight";
|
||||||
import {
|
import {
|
||||||
NEGATIVE_VIDEO_PROMPT,
|
NEGATIVE_VIDEO_PROMPT,
|
||||||
type VideoPrompt,
|
type VideoPrompt,
|
||||||
type VideoPromptType,
|
type VideoPromptType,
|
||||||
} from "../../lib/types";
|
} from "../../lib/types";
|
||||||
|
import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
|
||||||
|
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useToast } from "../../lib/toast";
|
||||||
|
|
||||||
interface VideoPromptsCardProps {
|
interface VideoPromptsCardProps {
|
||||||
prompts: VideoPrompt[];
|
prompts: VideoPrompt[];
|
||||||
@@ -34,6 +37,37 @@ export function VideoPromptsCard({
|
|||||||
const dirty = current?.prompt !== originalCurrent?.prompt;
|
const dirty = current?.prompt !== originalCurrent?.prompt;
|
||||||
|
|
||||||
const ref = useAutoHeight(current?.prompt ?? "", 120);
|
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 (
|
return (
|
||||||
<ResultCard
|
<ResultCard
|
||||||
@@ -100,13 +134,6 @@ export function VideoPromptsCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
|
||||||
Negative
|
Negative
|
||||||
@@ -115,6 +142,41 @@ export function VideoPromptsCard({
|
|||||||
{NEGATIVE_VIDEO_PROMPT}
|
{NEGATIVE_VIDEO_PROMPT}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
|
<p className="text-sm text-fg-muted">No prompt for this tab yet.</p>
|
||||||
|
|||||||
+10
-3
@@ -35,9 +35,12 @@
|
|||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
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 {
|
a, button, [role="button"], select, .cursor-pointer {
|
||||||
@@ -45,12 +48,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input, textarea {
|
input, textarea {
|
||||||
cursor: text;
|
cursor: url('/cursor-text.svg') 12 12, text;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-bg text-fg antialiased transition-colors duration-300;
|
@apply bg-bg text-fg antialiased transition-colors duration-300;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
@@ -188,18 +192,21 @@
|
|||||||
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
|
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
|
||||||
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
|
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
|
||||||
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
animation: blob-drift-1 18s ease-in-out infinite;
|
animation: blob-drift-1 18s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.liquid-blob-2 {
|
.liquid-blob-2 {
|
||||||
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
|
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
|
||||||
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
|
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
|
||||||
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
animation: blob-drift-2 22s ease-in-out infinite;
|
animation: blob-drift-2 22s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.liquid-blob-3 {
|
.liquid-blob-3 {
|
||||||
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
|
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
|
||||||
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
|
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
|
||||||
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
|
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
animation: blob-drift-3 26s ease-in-out infinite;
|
animation: blob-drift-3 26s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ export async function getServerStatus(
|
|||||||
return (await resp.json()) as ServerStatus;
|
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 {
|
export interface GenerateOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,3 +71,9 @@ export const SECTION_LABELS: Record<GenerationSection, string> = {
|
|||||||
video_prompts: "video prompts",
|
video_prompts: "video prompts",
|
||||||
youtube_description: "description",
|
youtube_description: "description",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface VideoAiLink {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+56
-31
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
|
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 { Link } from "react-router-dom";
|
||||||
import { InputPanel } from "../components/InputPanel";
|
import { InputPanel } from "../components/InputPanel";
|
||||||
import type { InputValues } from "../components/InputPanel";
|
import type { InputValues } from "../components/InputPanel";
|
||||||
@@ -26,7 +26,6 @@ import { useI18n } from "../lib/i18n";
|
|||||||
import { useTheme } from "../lib/theme";
|
import { useTheme } from "../lib/theme";
|
||||||
|
|
||||||
const DRAFT_KEY = "melodymuse-draft";
|
const DRAFT_KEY = "melodymuse-draft";
|
||||||
const DRAFT_DEBOUNCE_MS = 400;
|
|
||||||
|
|
||||||
const DEFAULT_INPUT: InputValues = {
|
const DEFAULT_INPUT: InputValues = {
|
||||||
idea: "",
|
idea: "",
|
||||||
@@ -103,9 +102,8 @@ export function HomePage() {
|
|||||||
const { t, lang, setLang } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
const [input, setInput] = useState<InputValues>(
|
const inputRef = useRef<InputValues>(loadDraft() ?? DEFAULT_INPUT);
|
||||||
() => loadDraft() ?? DEFAULT_INPUT,
|
const [forcedUpdate, setForcedUpdate] = useState<{ count: number; values: InputValues }>();
|
||||||
);
|
|
||||||
const [assets, setAssets] = useState<SongAssets | null>(null);
|
const [assets, setAssets] = useState<SongAssets | null>(null);
|
||||||
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
|
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -119,14 +117,14 @@ export function HomePage() {
|
|||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const anyRegenerating = isAnyBusy(loading, regenerating);
|
const anyRegenerating = isAnyBusy(loading, regenerating);
|
||||||
|
|
||||||
// Save draft
|
// Save draft periodically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => {
|
const id = window.setInterval(() => {
|
||||||
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); }
|
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(inputRef.current)); }
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
}, DRAFT_DEBOUNCE_MS);
|
}, 1000);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearInterval(id);
|
||||||
}, [input]);
|
}, []);
|
||||||
|
|
||||||
// Server health check
|
// Server health check
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -168,16 +166,19 @@ export function HomePage() {
|
|||||||
}, [anyRegenerating, cancel]);
|
}, [anyRegenerating, cancel]);
|
||||||
|
|
||||||
const buildRequest = useCallback(
|
const buildRequest = useCallback(
|
||||||
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({
|
(section: string, extra: Record<string, unknown> = {}): GenerateCall => {
|
||||||
input: input.idea.trim(),
|
const input = inputRef.current;
|
||||||
language: resolveLanguage(input),
|
return {
|
||||||
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
|
input: input.idea.trim(),
|
||||||
...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}),
|
language: resolveLanguage(input),
|
||||||
vocals: input.vocals,
|
...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
|
||||||
section,
|
...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}),
|
||||||
...extra,
|
vocals: input.vocals,
|
||||||
} as unknown as GenerateCall),
|
section,
|
||||||
[input],
|
...extra,
|
||||||
|
} as unknown as GenerateCall;
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const runGeneration = useCallback(
|
const runGeneration = useCallback(
|
||||||
@@ -212,6 +213,7 @@ export function HomePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
|
const input = inputRef.current;
|
||||||
if (!input.idea.trim()) return;
|
if (!input.idea.trim()) return;
|
||||||
setAssets(null);
|
setAssets(null);
|
||||||
setOriginalAssets(null);
|
setOriginalAssets(null);
|
||||||
@@ -228,9 +230,10 @@ export function HomePage() {
|
|||||||
setLoading,
|
setLoading,
|
||||||
t.assetsGenerated,
|
t.assetsGenerated,
|
||||||
);
|
);
|
||||||
}, [input, buildRequest, runGeneration, t]);
|
}, [buildRequest, runGeneration, t]);
|
||||||
|
|
||||||
const handleRegenerateAll = useCallback(async () => {
|
const handleRegenerateAll = useCallback(async () => {
|
||||||
|
const input = inputRef.current;
|
||||||
if (!input.idea.trim()) return;
|
if (!input.idea.trim()) return;
|
||||||
setAssets(null);
|
setAssets(null);
|
||||||
setOriginalAssets(null);
|
setOriginalAssets(null);
|
||||||
@@ -250,10 +253,11 @@ export function HomePage() {
|
|||||||
},
|
},
|
||||||
t.regeneratedAll,
|
t.regeneratedAll,
|
||||||
);
|
);
|
||||||
}, [input, buildRequest, runGeneration, t]);
|
}, [buildRequest, runGeneration, t]);
|
||||||
|
|
||||||
const handleRegenerateSection = useCallback(
|
const handleRegenerateSection = useCallback(
|
||||||
async (section: SectionKey, selectedText?: string) => {
|
async (section: SectionKey, selectedText?: string) => {
|
||||||
|
const input = inputRef.current;
|
||||||
if (!input.idea.trim() || !assets) return;
|
if (!input.idea.trim() || !assets) return;
|
||||||
|
|
||||||
const isPartialLyrics = section === "lyrics" && !!selectedText;
|
const isPartialLyrics = section === "lyrics" && !!selectedText;
|
||||||
@@ -269,8 +273,18 @@ export function HomePage() {
|
|||||||
let merged: SongAssets;
|
let merged: SongAssets;
|
||||||
if (isPartialLyrics) {
|
if (isPartialLyrics) {
|
||||||
// Replace only the selected text in the existing lyrics
|
// Replace only the selected text in the existing lyrics
|
||||||
const newPartial = (result as any).lyrics || (result as any).text || String(result);
|
let newPartial = (result as any).lyrics || (result as any).text || String(result);
|
||||||
const replaced = assets.lyrics.replace(selectedText, newPartial);
|
// 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 };
|
merged = { ...assets, lyrics: replaced };
|
||||||
} else {
|
} else {
|
||||||
merged = { ...assets, ...result };
|
merged = { ...assets, ...result };
|
||||||
@@ -289,7 +303,7 @@ export function HomePage() {
|
|||||||
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
|
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[input, assets, buildRequest, runGeneration, t],
|
[assets, buildRequest, runGeneration, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute chained regeneration
|
// Execute chained regeneration
|
||||||
@@ -335,7 +349,8 @@ export function HomePage() {
|
|||||||
const handleLoadHistory = useCallback(
|
const handleLoadHistory = useCallback(
|
||||||
(entry: HistoryEntry) => {
|
(entry: HistoryEntry) => {
|
||||||
if (anyRegenerating) cancel();
|
if (anyRegenerating) cancel();
|
||||||
setInput(entry.input);
|
inputRef.current = entry.input;
|
||||||
|
setForcedUpdate((prev) => ({ count: (prev?.count ?? 0) + 1, values: entry.input }));
|
||||||
setAssets(entry.assets);
|
setAssets(entry.assets);
|
||||||
setOriginalAssets(entry.assets);
|
setOriginalAssets(entry.assets);
|
||||||
setSelectedTitleIndex(0);
|
setSelectedTitleIndex(0);
|
||||||
@@ -371,11 +386,20 @@ export function HomePage() {
|
|||||||
href="https://suno.com/create"
|
href="https://suno.com/create"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="px-2 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-accent-primary hover:bg-accent-primary/10 transition-all duration-150 font-bold text-[11px] tracking-wide uppercase"
|
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"
|
title="Create on Suno"
|
||||||
>
|
>
|
||||||
Suno
|
Suno
|
||||||
</a>
|
</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
|
<a
|
||||||
href="https://www.youtube.com/@AIWentNonsense"
|
href="https://www.youtube.com/@AIWentNonsense"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -450,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 className={`w-full h-full rounded-full ${theme === "dark" ? "gradient-bg opacity-10" : "opacity-0"} transition-opacity duration-300`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-5 h-5 flex items-center justify-center z-10">
|
<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>
|
||||||
<div className="w-5 h-5 flex items-center justify-center z-10">
|
<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"}`} />
|
<FontAwesomeIcon icon={faMoon} className={`w-2.5 h-2.5 transition-colors ${theme === "dark" ? "text-accent-primary" : "text-fg-muted/60"}`} />
|
||||||
@@ -467,8 +491,9 @@ export function HomePage() {
|
|||||||
<div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col">
|
<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">
|
<div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0">
|
||||||
<InputPanel
|
<InputPanel
|
||||||
values={input}
|
initialValues={inputRef.current}
|
||||||
onChange={setInput}
|
forcedUpdate={forcedUpdate}
|
||||||
|
onChange={(next) => { inputRef.current = next; }}
|
||||||
onSubmit={handleGenerate}
|
onSubmit={handleGenerate}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={anyRegenerating}
|
disabled={anyRegenerating}
|
||||||
|
|||||||
+220
-2
@@ -9,11 +9,16 @@ import {
|
|||||||
faPlug,
|
faPlug,
|
||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
faServer,
|
faServer,
|
||||||
|
faVideo,
|
||||||
|
faTerminal,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useToast } from "../lib/toast";
|
import { useToast } from "../lib/toast";
|
||||||
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
||||||
import { clearHistory } from "../lib/history";
|
import { clearHistory } from "../lib/history";
|
||||||
import { useI18n } from "../lib/i18n";
|
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";
|
const DRAFT_KEY = "melodymuse-draft";
|
||||||
|
|
||||||
@@ -41,6 +46,25 @@ export function SettingsPage() {
|
|||||||
hasDraft: false,
|
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) => {
|
const check = async (signal?: AbortSignal) => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setStatusError(null);
|
setStatusError(null);
|
||||||
@@ -76,10 +100,70 @@ export function SettingsPage() {
|
|||||||
toast.info(t.clearDraft);
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
@@ -92,7 +176,7 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="card p-6 space-y-6">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
|
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
|
||||||
@@ -177,6 +261,140 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="text-center pt-2">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user