Compare commits

..
4 Commits
8 changed files with 423 additions and 41 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 # 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"
+92 -2
View File
@@ -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}`);
@@ -349,6 +364,23 @@ 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 idx = part.indexOf('=');
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); }
@@ -466,6 +498,10 @@ async function handleGenerate(req, res) {
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 +527,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 +583,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);
} }
+71 -38
View File
@@ -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.
@@ -49,10 +50,8 @@ REQUIRED JSON STRUCTURE:
} }
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, Recommended video tools (choose the best fit per prompt): {{VIDEO_TOOLS}}. Never recommend Sora.`,
Luma Dream Machine, Pika 2.0, Haiper. Never recommend Sora.`; partial: `You are a music production assistant specializing in Suno AI song creation.
const SYSTEM_PROMPT_PARTIAL = `You are a music production assistant specializing in Suno AI song creation.
The user has an existing song and wants to regenerate ONE section. Output ONLY 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.
@@ -80,11 +79,10 @@ QUALITY CONSTRAINTS:
- 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: 5-second seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters. inside each prompt. Keep prompts under 900 characters. Recommended tools: {{VIDEO_TOOLS}}. Never recommend Sora.
- 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 +99,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 +120,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 +131,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) {
@@ -155,29 +183,34 @@ export function buildUserMessage(req) {
req.context?.lyrics ?? '', req.context?.lyrics ?? '',
'', '',
'Selected passage to rewrite:', 'Selected passage to rewrite:',
req.selected_text ?? '', req.passage ?? '',
].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()}"`;
} }
+20
View File
@@ -9,6 +9,8 @@ import {
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";
interface VideoPromptsCardProps { interface VideoPromptsCardProps {
prompts: VideoPrompt[]; prompts: VideoPrompt[];
@@ -34,6 +36,7 @@ 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();
return ( return (
<ResultCard <ResultCard
@@ -115,6 +118,23 @@ export function VideoPromptsCard({
{NEGATIVE_VIDEO_PROMPT} {NEGATIVE_VIDEO_PROMPT}
</p> </p>
</div> </div>
{links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary/10 text-accent-primary hover:bg-accent-primary hover:text-white transition-all duration-150 border border-accent-primary/20 hover:border-accent-primary"
>
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className="w-3 h-3" />
</a>
))}
</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>
+1 -1
View File
@@ -35,7 +35,6 @@
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;
} }
@@ -51,6 +50,7 @@
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;
} }
+12
View File
@@ -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;
} }
+6
View File
@@ -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;
}
+218
View File
@@ -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,6 +100,66 @@ 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">
@@ -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}
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-[300px] 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="/"