feat: Text generation suggestions via Gemini models

Backend:
- New text_gen.py module with Gemini text generation client
  - suggest_prompts(): generates 3 prompt suggestions from user input
  - get_text_gen_config(): loads config from .env (model, API key, safety)
  - get_available_models(): returns list of Gemini text models
  - Safety settings: harassment, hate_speech, sexually_explicit, dangerous
    Configurable via TEXT_GEN_SAFETY_* env vars (default: BLOCK_ONLY_HIGH)
- New endpoints:
  * POST /api/text-gen/suggest — generates 3 prompt suggestions
  * GET /api/text-gen/models — lists available text models
  * GET /api/text-gen/config — returns safe config (API key masked)
  * POST /api/text-gen/config — updates config in-memory
- Default model: gemini-2.5-flash
- Fallback API key: LYRIA_API_KEY if TEXT_GEN_API_KEY not set
- System prompt: expert music producer persona for rich musical detail

Frontend:
- New SuggestionsModal component for displaying generated suggestions
  - Auto-fetches when opened
  - Shows loading spinner, error state, or suggestion list
  - Each suggestion clickable to populate the prompt field
  - Hover effect: 'Click to use this prompt →'
- Generator component updated with 'Get Suggestions ✨' button
  - Appears next to prompt label in both Basic and Advanced modes
  - Disabled when no input present
  - Clicking suggestion populates the prompt/structured fields
- SettingsModal expanded with Text Generation section (collapsible)
  - Same pattern as Image Generation: model, API key status, safety filters
  - Editable dropdowns with Save Changes button
  - Refactored to use shared useGenSettings hook for both sections

Models Available:
- gemini-2.5-flash (GA, default) — best price-to-performance
- gemini-2.5-pro (GA) — highest quality reasoning
- gemini-3.5-flash (Preview) — next-gen Flash
- gemini-3.1-flash (Preview) — latest with thinking

Architecture KB and Tasks KB updated.
This commit is contained in:
rin.kagamine committed 2026-05-29 22:37:35 -04:00
1 parent 70beb89ad9
commit e687f7d457
5 files changed
+544 -165

No files matched your search

+50 -2
View File
@@ -14,9 +14,8 @@ from database import init_db, create_clip, get_clips, get_clip, delete_clip, upd
from lyria_client import generate_clip, save_clip_file from lyria_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip from analysis import analyze_clip
from image_gen import generate_artwork, get_image_gen_config, get_available_models
from analysis import analyze_clip
from image_gen import generate_artwork, get_image_gen_config, get_available_models, SAFETY_LEVELS from image_gen import generate_artwork, get_image_gen_config, get_available_models, SAFETY_LEVELS
from text_gen import suggest_prompts, get_text_gen_config, get_available_models as get_text_models
app = FastAPI(title="Lyria Studio", version="0.1.0") app = FastAPI(title="Lyria Studio", version="0.1.0")
@@ -320,6 +319,55 @@ async def update_image_gen_configuration(request: dict):
return {"status": "updated"} return {"status": "updated"}
# ─── Text Generation Endpoints ──────────────────────────────────────
@app.get("/api/text-gen/models")
async def list_text_gen_models():
"""List available text generation models."""
return {"models": get_text_models()}
@app.get("/api/text-gen/config")
async def get_text_gen_configuration():
"""Get current text generation configuration (safe values only)."""
config = get_text_gen_config()
return {
"model": config["model"],
"safety_harassment": config["safety_harassment"],
"safety_hate_speech": config["safety_hate_speech"],
"safety_sexually_explicit": config["safety_sexually_explicit"],
"safety_dangerous": config["safety_dangerous"],
"has_api_key": bool(config.get("api_key")),
}
@app.post("/api/text-gen/config")
async def update_text_gen_configuration(request: dict):
"""Update text generation configuration (in-memory only)."""
if "model" in request:
os.environ["TEXT_GEN_MODEL"] = request["model"]
if "safety_harassment" in request:
os.environ["TEXT_GEN_SAFETY_HARASSMENT"] = request["safety_harassment"]
if "safety_hate_speech" in request:
os.environ["TEXT_GEN_SAFETY_HATE_SPEECH"] = request["safety_hate_speech"]
if "safety_sexually_explicit" in request:
os.environ["TEXT_GEN_SAFETY_SEXUALLY_EXPLICIT"] = request["safety_sexually_explicit"]
if "safety_dangerous" in request:
os.environ["TEXT_GEN_SAFETY_DANGEROUS"] = request["safety_dangerous"]
return {"status": "updated"}
@app.post("/api/text-gen/suggest")
async def get_suggestions(request: dict):
"""Get prompt suggestions based on user input."""
user_input = request.get("input", "").strip()
if not user_input:
raise HTTPException(status_code=400, detail="Input is required")
try:
config = get_text_gen_config()
suggestions = await suggest_prompts(user_input, config=config)
return {"suggestions": suggestions}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
@app.patch("/api/clips/{clip_id}") @app.patch("/api/clips/{clip_id}")
async def rename_clip_endpoint(clip_id: str, request: RenameRequest): async def rename_clip_endpoint(clip_id: str, request: RenameRequest):
clip = await get_clip(clip_id) clip = await get_clip(clip_id)
+140
View File
@@ -0,0 +1,140 @@
"""Text generation client using Google Gemini models for prompt suggestions."""
import os
from typing import Optional
from google import genai
from google.genai import types
# ── Configuration ──────────────────────────────────────────────────────────
def get_text_gen_config():
"""Load text generation configuration from environment."""
return {
"api_key": os.environ.get("TEXT_GEN_API_KEY") or os.environ.get("LYRIA_API_KEY"),
"model": os.environ.get("TEXT_GEN_MODEL", "gemini-2.5-flash"),
"safety_harassment": os.environ.get("TEXT_GEN_SAFETY_HARASSMENT", "BLOCK_ONLY_HIGH"),
"safety_hate_speech": os.environ.get("TEXT_GEN_SAFETY_HATE_SPEECH", "BLOCK_ONLY_HIGH"),
"safety_sexually_explicit": os.environ.get("TEXT_GEN_SAFETY_SEXUALLY_EXPLICIT", "BLOCK_ONLY_HIGH"),
"safety_dangerous": os.environ.get("TEXT_GEN_SAFETY_DANGEROUS", "BLOCK_ONLY_HIGH"),
}
# ── Safety Settings ────────────────────────────────────────────────────────
def build_safety_settings(config: dict) -> list:
"""Build safety settings from config."""
return [
types.SafetySetting(
category="HARM_CATEGORY_HARASSMENT",
threshold=config.get("safety_harassment", "BLOCK_ONLY_HIGH"),
),
types.SafetySetting(
category="HARM_CATEGORY_HATE_SPEECH",
threshold=config.get("safety_hate_speech", "BLOCK_ONLY_HIGH"),
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold=config.get("safety_sexually_explicit", "BLOCK_ONLY_HIGH"),
),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT",
threshold=config.get("safety_dangerous", "BLOCK_ONLY_HIGH"),
),
]
# ── Prompt Suggestion ──────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are an expert music producer and prompt engineer for AI music generation models.
Your task is to help users write better prompts for generating music with AI models like Google Lyria.
When given a user's idea or partial prompt, expand it into detailed, evocative music descriptions.
Include specific details about:
- Genre and subgenre
- Instrumentation and sound design
- Mood, energy, and emotional arc
- Tempo and rhythmic feel
- Structure (intro, build, drop, breakdown, etc.)
- Production style (clean, lo-fi, distorted, etc.)
Keep suggestions concise but rich in musical detail. Use terminology that music producers would use.
"""
async def suggest_prompts(user_input: str, model: str = None, config: dict = None) -> list:
"""Generate prompt suggestions based on user input.
Returns a list of suggestion strings.
"""
cfg = config or get_text_gen_config()
client = genai.Client(api_key=cfg["api_key"])
safety = build_safety_settings(cfg)
prompt = f"""The user wants to generate music with an AI music model. They described their idea as:
"{user_input}"
Please provide 3 distinct, detailed prompts they could use. Each prompt should be a single paragraph that vividly describes the music. Make them different from each other — explore different angles of the user's idea.
Format your response as a numbered list (1., 2., 3.) with just the prompts, no extra commentary."""
response = client.models.generate_content(
model=model or cfg["model"],
contents=prompt,
config=types.GenerateContentConfig(
system_instruction=SYSTEM_PROMPT,
safety_settings=safety,
temperature=0.9,
max_output_tokens=2048,
),
)
# Parse response into list of suggestions
text = response.text.strip()
suggestions = []
# Split by numbered lines (1., 2., 3.)
import re
# Find all text after numbers
matches = re.findall(r'(?:^|\n)\d+\.\s*(.*?)(?=\n\d+\.|$)', text, re.DOTALL)
if matches:
suggestions = [m.strip() for m in matches if m.strip()]
else:
# Fallback: split by newlines and filter empty
lines = [line.strip() for line in text.split('\n') if line.strip()]
# Remove lines that are just numbers
suggestions = [line for line in lines if not re.match(r'^\d+\.$', line)]
return suggestions[:3] # Ensure max 3
# ── Available Models ───────────────────────────────────────────────────────
AVAILABLE_TEXT_MODELS = [
{
"id": "gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"description": "Best price-to-performance. Fast, capable, 1M context.",
"status": "GA",
},
{
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"description": "Highest quality reasoning and coding. Slower, more expensive.",
"status": "GA",
},
{
"id": "gemini-3.5-flash",
"name": "Gemini 3.5 Flash",
"description": "Next-gen Flash. Strong reasoning, fast inference.",
"status": "Preview",
},
{
"id": "gemini-3.1-flash",
"name": "Gemini 3.1 Flash",
"description": "Latest Flash with thinking capabilities.",
"status": "Preview",
},
]
def get_available_models() -> list:
"""Return list of available text generation models."""
return AVAILABLE_TEXT_MODELS
+38 -6
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor' import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor'
import SuggestionsModal from './SuggestionsModal'
interface ModelInfo { interface ModelInfo {
name: string name: string
@@ -23,6 +24,7 @@ export default function Generator({ onGenerate }: Props) {
const [models, setModels] = useState<Record<string, ModelInfo>>({}) const [models, setModels] = useState<Record<string, ModelInfo>>({})
const [promptMode, setPromptMode] = useState<PromptMode>('basic') const [promptMode, setPromptMode] = useState<PromptMode>('basic')
const [structuredPrompt, setStructuredPrompt] = useState<PromptStructure>({}) const [structuredPrompt, setStructuredPrompt] = useState<PromptStructure>({})
const [suggestionsOpen, setSuggestionsOpen] = useState(false)
useEffect(() => { useEffect(() => {
fetch('/api/models') fetch('/api/models')
@@ -181,9 +183,17 @@ export default function Generator({ onGenerate }: Props) {
{/* Basic Prompt */} {/* Basic Prompt */}
{(promptMode === 'basic' || !isPro) && ( {(promptMode === 'basic' || !isPro) && (
<div> <div>
<label className="block text-xs text-lyria-muted mb-1.5"> <div className="flex items-center justify-between mb-1.5">
Prompt <label className="text-xs text-lyria-muted">Prompt</label>
</label> <button
type="button"
onClick={() => setSuggestionsOpen(true)}
disabled={!prompt.trim()}
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors disabled:opacity-40"
>
Get Suggestions ✨
</button>
</div>
<textarea <textarea
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
@@ -196,9 +206,17 @@ export default function Generator({ onGenerate }: Props) {
{/* Advanced Prompt */} {/* Advanced Prompt */}
{promptMode === 'advanced' && isPro && ( {promptMode === 'advanced' && isPro && (
<div> <div>
<label className="block text-xs text-lyria-muted mb-1.5"> <div className="flex items-center justify-between mb-1.5">
Structured Prompt <label className="text-xs text-lyria-muted">Structured Prompt</label>
</label> <button
type="button"
onClick={() => setSuggestionsOpen(true)}
disabled={Object.values(structuredPrompt).every(v => !v?.trim())}
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors disabled:opacity-40"
>
Get Suggestions ✨
</button>
</div>
<StructuredPromptEditor <StructuredPromptEditor
value={structuredPrompt} value={structuredPrompt}
onChange={setStructuredPrompt} onChange={setStructuredPrompt}
@@ -218,6 +236,20 @@ export default function Generator({ onGenerate }: Props) {
{isLoading ? 'Generating...' : 'Generate'} {isLoading ? 'Generating...' : 'Generate'}
</button> </button>
</form> </form>
<SuggestionsModal
isOpen={suggestionsOpen}
onClose={() => setSuggestionsOpen(false)}
onSelect={(suggestion) => {
if (promptMode === 'basic' || !isPro) {
setPrompt(suggestion)
} else {
// For advanced mode, put it in the 'style' field or additional
setStructuredPrompt(prev => ({ ...prev, additional: suggestion }))
}
}}
userInput={promptMode === 'basic' || !isPro ? prompt : Object.values(structuredPrompt).filter(Boolean).join(' ')}
/>
</div> </div>
) )
} }
+186 -157
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
interface ImageGenConfig { interface GenConfig {
model: string model: string
has_api_key: boolean has_api_key: boolean
safety_harassment: string safety_harassment: string
@@ -9,7 +9,7 @@ interface ImageGenConfig {
safety_dangerous: string safety_dangerous: string
} }
interface ImageGenModel { interface GenModel {
id: string id: string
name: string name: string
description: string description: string
@@ -32,49 +32,97 @@ const SAFETY_OPTIONS = [
{ value: 'BLOCK_LOW_AND_ABOVE', label: 'Block Most (Low+)' }, { value: 'BLOCK_LOW_AND_ABOVE', label: 'Block Most (Low+)' },
] ]
function useGenSettings(apiPrefix: string) {
const [models, setModels] = useState<GenModel[]>([])
const [config, setConfig] = useState<GenConfig | null>(null)
const [edited, setEdited] = useState<Partial<GenConfig>>({})
const [expanded, setExpanded] = useState(false)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState('')
const fetchModels = async () => {
try {
const res = await fetch(`/api/${apiPrefix}/models`)
if (res.ok) {
const data = await res.json()
setModels(data.models)
}
} catch (e) {
console.error(`Failed to fetch ${apiPrefix} models:`, e)
}
}
const fetchConfig = async () => {
try {
const res = await fetch(`/api/${apiPrefix}/config`)
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error(`Failed to fetch ${apiPrefix} config:`, e)
}
}
const save = async () => {
if (!edited || Object.keys(edited).length === 0) return
setSaving(true)
try {
const res = await fetch(`/api/${apiPrefix}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edited),
})
if (res.ok) {
setSaveMessage('Settings saved!')
setConfig(prev => prev ? { ...prev, ...edited } : prev)
setEdited({})
setTimeout(() => setSaveMessage(''), 2000)
} else {
setSaveMessage('Failed to save')
}
} catch (e) {
setSaveMessage('Failed to save')
} finally {
setSaving(false)
}
}
const update = (key: string, value: string) => {
setEdited(prev => ({ ...prev, [key]: value }))
setSaveMessage('')
}
const getValue = (key: keyof GenConfig): string => {
const val = edited[key] ?? config?.[key]
return typeof val === 'string' ? val : 'BLOCK_ONLY_HIGH'
}
const hasChanges = Object.keys(edited).length > 0
return {
models, config, edited, expanded, setExpanded,
saving, saveMessage, fetchModels, fetchConfig,
save, update, getValue, hasChanges,
}
}
export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDeleteAll, clipCount }: Props) { export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDeleteAll, clipCount }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [imageGenModels, setImageGenModels] = useState<ImageGenModel[]>([]) const imageGen = useGenSettings('image-gen')
const [imageGenConfig, setImageGenConfig] = useState<ImageGenConfig | null>(null) const textGen = useGenSettings('text-gen')
const [editedConfig, setEditedConfig] = useState<Partial<ImageGenConfig>>({})
const [showImageGenSettings, setShowImageGenSettings] = useState(false)
const [saveMessage, setSaveMessage] = useState('')
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
fetchImageGenModels() imageGen.fetchModels()
fetchImageGenConfig() imageGen.fetchConfig()
setEditedConfig({}) textGen.fetchModels()
setSaveMessage('') textGen.fetchConfig()
} }
}, [isOpen]) }, [isOpen])
const fetchImageGenModels = async () => {
try {
const res = await fetch('/api/image-gen/models')
if (res.ok) {
const data = await res.json()
setImageGenModels(data.models)
}
} catch (e) {
console.error('Failed to fetch image gen models:', e)
}
}
const fetchImageGenConfig = async () => {
try {
const res = await fetch('/api/image-gen/config')
if (res.ok) {
const data = await res.json()
setImageGenConfig(data)
}
} catch (e) {
console.error('Failed to fetch image gen config:', e)
}
}
if (!isOpen) return null if (!isOpen) return null
const handleClearWaveforms = async () => { const handleClearWaveforms = async () => {
@@ -100,41 +148,102 @@ export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDel
} }
} }
const handleSaveImageGenConfig = async () => { const renderGenSection = (
if (!editedConfig || Object.keys(editedConfig).length === 0) return title: string,
setSaving(true) icon: React.ReactNode,
try { gen: ReturnType<typeof useGenSettings>
const res = await fetch('/api/image-gen/config', { ) => (
method: 'POST', <div className="border border-white/10 rounded-lg overflow-hidden">
headers: { 'Content-Type': 'application/json' }, <button
body: JSON.stringify(editedConfig), onClick={() => gen.setExpanded(!gen.expanded)}
}) className="w-full flex items-center justify-between px-4 py-3 bg-white/5 hover:bg-white/10 transition-colors"
if (res.ok) { >
setSaveMessage('Settings saved!') <div className="flex items-center gap-2">
setImageGenConfig(prev => prev ? { ...prev, ...editedConfig } : prev) {icon}
setEditedConfig({}) <span className="text-sm font-medium text-white">{title}</span>
setTimeout(() => setSaveMessage(''), 2000) </div>
} else { <svg
setSaveMessage('Failed to save') className={`w-4 h-4 text-lyria-muted transition-transform ${gen.expanded ? 'rotate-180' : ''}`}
} fill="none"
} catch (e) { stroke="currentColor"
setSaveMessage('Failed to save') viewBox="0 0 24 24"
} finally { >
setSaving(false) <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
} </svg>
} </button>
const updateEditedConfig = (key: string, value: string) => { {gen.expanded && (
setEditedConfig(prev => ({ ...prev, [key]: value })) <div className="p-4 space-y-4">
setSaveMessage('') {/* Model Selection */}
} <div>
<label className="block text-xs text-lyria-muted mb-1.5">Model</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={gen.getValue('model')}
onChange={(e) => gen.update('model', e.target.value)}
>
{gen.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.status})
</option>
))}
</select>
</div>
const getConfigValue = (key: 'model' | 'safety_harassment' | 'safety_hate_speech' | 'safety_sexually_explicit' | 'safety_dangerous'): string => { {/* API Key Status */}
const val = editedConfig[key] ?? imageGenConfig?.[key] <div className="flex items-center gap-2">
return typeof val === 'string' ? val : 'BLOCK_ONLY_HIGH' <div className={`w-2 h-2 rounded-full ${gen.config?.has_api_key ? 'bg-green-400' : 'bg-red-400'}`} />
} <span className="text-xs text-lyria-muted">
{gen.config?.has_api_key ? 'API key configured' : 'No API key — set in .env'}
</span>
</div>
const hasChanges = Object.keys(editedConfig).length > 0 {/* Safety Settings */}
<div className="space-y-3">
<p className="text-xs font-medium text-white">Safety Filters</p>
{[
{ key: 'safety_harassment', label: 'Harassment' },
{ key: 'safety_hate_speech', label: 'Hate Speech' },
{ key: 'safety_sexually_explicit', label: 'Sexually Explicit' },
{ key: 'safety_dangerous', label: 'Dangerous Content' },
].map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-lyria-muted mb-1">{label}</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-1.5 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={gen.getValue(key as keyof GenConfig)}
onChange={(e) => gen.update(key, e.target.value)}
>
{SAFETY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
{/* Save Button */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={gen.save}
disabled={!gen.hasChanges || gen.saving}
className="px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 bg-lyria-accent text-black hover:bg-lyria-accent/90"
>
{gen.saving ? 'Saving...' : 'Save Changes'}
</button>
{gen.saveMessage && (
<span className={`text-xs ${gen.saveMessage.includes('Failed') ? 'text-red-400' : 'text-green-400'}`}>
{gen.saveMessage}
</span>
)}
</div>
<p className="text-xs text-lyria-muted/60">
Changes apply immediately but reset on server restart. Set .env for persistence.
</p>
</div>
)}
</div>
)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -162,99 +271,19 @@ export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDel
{/* Content */} {/* Content */}
<div className="px-6 py-5 space-y-6 overflow-y-auto"> <div className="px-6 py-5 space-y-6 overflow-y-auto">
{/* Image Generation Settings */} {/* Image Generation */}
<div className="border border-white/10 rounded-lg overflow-hidden"> {renderGenSection('Image Generation', (
<button
onClick={() => setShowImageGenSettings(!showImageGenSettings)}
className="w-full flex items-center justify-between px-4 py-3 bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-lyria-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-lyria-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
<span className="text-sm font-medium text-white">Image Generation</span> ), imageGen)}
</div>
<svg {/* Text Generation */}
className={`w-4 h-4 text-lyria-muted transition-transform ${showImageGenSettings ? 'rotate-180' : ''}`} {renderGenSection('Text Generation', (
fill="none" <svg className="w-4 h-4 text-lyria-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
stroke="currentColor" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> ), textGen)}
{showImageGenSettings && (
<div className="p-4 space-y-4">
{/* Model Selection */}
<div>
<label className="block text-xs text-lyria-muted mb-1.5">Model</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={getConfigValue('model')}
onChange={(e) => updateEditedConfig('model', e.target.value)}
>
{imageGenModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.status})
</option>
))}
</select>
</div>
{/* API Key Status */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${imageGenConfig?.has_api_key ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-xs text-lyria-muted">
{imageGenConfig?.has_api_key ? 'API key configured' : 'No API key — set IMAGE_GEN_API_KEY in .env'}
</span>
</div>
{/* Safety Settings */}
<div className="space-y-3">
<p className="text-xs font-medium text-white">Safety Filters</p>
{[
{ key: 'safety_harassment', label: 'Harassment' },
{ key: 'safety_hate_speech', label: 'Hate Speech' },
{ key: 'safety_sexually_explicit', label: 'Sexually Explicit' },
{ key: 'safety_dangerous', label: 'Dangerous Content' },
].map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-lyria-muted mb-1">{label}</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-1.5 text-sm text-white focus:outline-none focus:border-lyria-accent"
value={getConfigValue(key as 'safety_harassment' | 'safety_hate_speech' | 'safety_sexually_explicit' | 'safety_dangerous')}
onChange={(e) => updateEditedConfig(key, e.target.value)}
>
{SAFETY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
{/* Save Button */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSaveImageGenConfig}
disabled={!hasChanges || saving}
className="px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 bg-lyria-accent text-black hover:bg-lyria-accent/90"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{saveMessage && (
<span className={`text-xs ${saveMessage.includes('Failed') ? 'text-red-400' : 'text-green-400'}`}>
{saveMessage}
</span>
)}
</div>
<p className="text-xs text-lyria-muted/60">
Changes apply immediately but reset on server restart. Set .env for persistence.
</p>
</div>
)}
</div>
{/* Danger Zone */} {/* Danger Zone */}
<div className="border border-red-500/30 rounded-lg overflow-hidden"> <div className="border border-red-500/30 rounded-lg overflow-hidden">
@@ -0,0 +1,130 @@
import { useState, useEffect } from 'react'
interface Props {
isOpen: boolean
onClose: () => void
onSelect: (suggestion: string) => void
userInput: string
}
export default function SuggestionsModal({ isOpen, onClose, onSelect, userInput }: Props) {
const [suggestions, setSuggestions] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fetchSuggestions = async () => {
if (!userInput.trim()) return
setLoading(true)
setError('')
try {
const res = await fetch('/api/text-gen/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: userInput }),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || 'Failed to get suggestions')
}
const data = await res.json()
setSuggestions(data.suggestions || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to get suggestions')
} finally {
setLoading(false)
}
}
// Auto-fetch when opened
useEffect(() => {
if (isOpen && suggestions.length === 0 && !loading && !error) {
fetchSuggestions()
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/80 backdrop-blur-md"
onClick={onClose}
/>
<div className="relative bg-lyria-surface border border-white/10 rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<div>
<h2 className="text-lg font-semibold text-white">Prompt Suggestions</h2>
<p className="text-xs text-lyria-muted mt-0.5 truncate max-w-[280px]">{userInput}</p>
</div>
<button
onClick={onClose}
className="text-lyria-muted hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="px-6 py-5 overflow-y-auto">
{loading && (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin w-6 h-6 text-lyria-accent" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
<p className="text-sm text-red-400">{error}</p>
<button
onClick={fetchSuggestions}
className="mt-2 text-xs text-lyria-accent hover:text-lyria-accent/80"
>
Try again
</button>
</div>
)}
{!loading && !error && suggestions.length === 0 && (
<div className="text-center py-8">
<p className="text-sm text-lyria-muted">No suggestions yet. Click below to generate.</p>
<button
onClick={fetchSuggestions}
className="mt-3 px-4 py-2 text-sm bg-lyria-accent text-black rounded-md hover:bg-lyria-accent/90"
>
Get Suggestions
</button>
</div>
)}
<div className="space-y-3">
{suggestions.map((suggestion, i) => (
<button
key={i}
onClick={() => {
onSelect(suggestion)
onClose()
}}
className="w-full text-left p-4 bg-white/5 hover:bg-white/10 border border-white/5 hover:border-lyria-accent/30 rounded-lg transition-all group"
>
<div className="flex items-start gap-3">
<span className="text-lyria-accent font-mono text-sm shrink-0">{i + 1}.</span>
<p className="text-sm text-white/90 leading-relaxed">{suggestion}</p>
</div>
<div className="mt-2 text-xs text-lyria-accent opacity-0 group-hover:opacity-100 transition-opacity">
Click to use this prompt →
</div>
</button>
))}
</div>
</div>
</div>
</div>
)
}