From 70beb89ad997fc70fac8b36fd974eff6d560de87 Mon Sep 17 00:00:00 2001
From: Kagamine Rin
Date: Fri, 29 May 2026 22:28:52 -0400
Subject: [PATCH] feat: Structured prompt editing for Pro model
Backend:
- Added prompt_structure JSON column to clips DB (migration in init_db)
- Updated GenerateRequest model with optional prompt_structure dict
- build_prompt() method combines structured fields into single prompt:
'Generate a track with the following elements:\n\n**Style:** [style]\n...'
- create_clip() accepts and stores prompt_structure JSON
- get_clips()/get_clip() parse prompt_structure back to dict
Frontend:
- New StructuredPromptEditor component with 8 collapsible fields:
Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional
- Fields show filled/empty state with colored dots
- Expand/collapse individual fields
- 'Expand All' button
- Counter: 'X of 8 fields filled'
- Generator component updated with Basic/Advanced mode toggle (Pro only)
- Basic: single textarea (works for both models)
- Advanced: StructuredPromptEditor (Pro only)
- Mode resets to Basic when switching to Clip Preview
- DetailPanel shows structured prompt breakdown when present
- Each filled field shown in its own labeled box
- Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional
- Clip interface updated with prompt_structure field across all components
Architecture KB and Tasks KB updated.
---
backend/src/database.py | 25 ++++-
backend/src/main.py | 9 +-
backend/src/models.py | 24 ++++
frontend/src/App.tsx | 1 +
frontend/src/components/DetailPanel.tsx | 29 +++++
frontend/src/components/Gallery.tsx | 1 +
frontend/src/components/Generator.tsx | 103 +++++++++++++----
.../src/components/StructuredPromptEditor.tsx | 106 ++++++++++++++++++
8 files changed, 273 insertions(+), 25 deletions(-)
create mode 100644 frontend/src/components/StructuredPromptEditor.tsx
diff --git a/backend/src/database.py b/backend/src/database.py
index 4ea1d9d..210d51d 100644
--- a/backend/src/database.py
+++ b/backend/src/database.py
@@ -78,15 +78,20 @@ async def init_db():
await db.execute("ALTER TABLE clips ADD COLUMN key TEXT")
except Exception:
pass # Column already exists
+ # Migration: add prompt_structure column if it doesn't exist
+ try:
+ await db.execute("ALTER TABLE clips ADD COLUMN prompt_structure TEXT")
+ except Exception:
+ pass # Column already exists
await db.commit()
-async def create_clip(prompt: str, model: str, duration: int) -> str:
+async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str:
clip_id = str(uuid.uuid4())
filename = f"{clip_id}.mp3"
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
- "INSERT INTO clips (id, prompt, model, duration_seconds, filename, status) VALUES (?, ?, ?, ?, ?, ?)",
- (clip_id, prompt, model, duration, filename, "generating")
+ "INSERT INTO clips (id, prompt, model, duration_seconds, filename, status, prompt_structure) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (clip_id, prompt, model, duration, filename, "generating", prompt_structure)
)
await db.commit()
return clip_id
@@ -121,6 +126,13 @@ async def get_clips(limit: int = 50, offset: int = 0):
clips = [dict(row) for row in rows]
for clip in clips:
clip["created_at"] = format_timestamp(clip.get("created_at"))
+ # Parse prompt_structure JSON
+ if clip.get("prompt_structure"):
+ try:
+ import json
+ clip["prompt_structure"] = json.loads(clip["prompt_structure"])
+ except Exception:
+ clip["prompt_structure"] = None
return clips
async def get_clip(clip_id: str):
@@ -131,6 +143,13 @@ async def get_clip(clip_id: str):
if row:
clip = dict(row)
clip["created_at"] = format_timestamp(clip.get("created_at"))
+ # Parse prompt_structure JSON
+ if clip.get("prompt_structure"):
+ try:
+ import json
+ clip["prompt_structure"] = json.loads(clip["prompt_structure"])
+ except Exception:
+ clip["prompt_structure"] = None
return clip
return None
diff --git a/backend/src/main.py b/backend/src/main.py
index 9fe2a94..1114e1a 100644
--- a/backend/src/main.py
+++ b/backend/src/main.py
@@ -77,13 +77,18 @@ async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: in
@app.post("/api/generate", response_model=GenerateResponse)
async def generate(request: GenerateRequest, background_tasks: BackgroundTasks):
duration = request.get_effective_duration()
- clip_id = await create_clip(request.prompt, request.model, duration)
+ # Build prompt from structured fields if present
+ final_prompt = request.build_prompt()
+ # Serialize structured prompt for DB storage
+ import json
+ prompt_structure_json = json.dumps(request.prompt_structure) if request.prompt_structure else None
+ clip_id = await create_clip(final_prompt, request.model, duration, prompt_structure_json)
# Kick off generation in background
background_tasks.add_task(
_generate_and_save,
clip_id,
- request.prompt,
+ final_prompt,
request.model,
duration
)
diff --git a/backend/src/models.py b/backend/src/models.py
index fb0ec61..3286a65 100644
--- a/backend/src/models.py
+++ b/backend/src/models.py
@@ -24,6 +24,7 @@ class GenerateRequest(BaseModel):
prompt: str
model: str = DEFAULT_MODEL
duration: Optional[int] = None # defaults to model default
+ prompt_structure: Optional[dict] = None # structured prompt fields for Pro mode
@field_validator('model')
@classmethod
@@ -40,6 +41,26 @@ class GenerateRequest(BaseModel):
dur = self.duration if self.duration is not None else info["default_duration"]
return min(dur, info["max_duration"])
+ def build_prompt(self) -> str:
+ """Build the final prompt from structured fields or return the simple prompt."""
+ if self.prompt_structure:
+ parts = ["Generate a track with the following elements:"]
+ field_order = [
+ ("style", "Style"),
+ ("timbre", "Timbre"),
+ ("structure", "Structure"),
+ ("mood", "Mood"),
+ ("tempo", "Tempo"),
+ ("instrumentation", "Instrumentation"),
+ ("effects", "Effects"),
+ ("additional", "Additional Notes"),
+ ]
+ for key, label in field_order:
+ if key in self.prompt_structure and self.prompt_structure[key]:
+ parts.append(f"**{label}:** {self.prompt_structure[key]}")
+ return "\n\n".join(parts)
+ return self.prompt
+
class GenerateResponse(BaseModel):
id: str
status: str
@@ -57,6 +78,9 @@ class Clip(BaseModel):
status: str
error_message: Optional[str] = None
created_at: datetime
+ prompt_structure: Optional[dict] = None
+ bpm: Optional[float] = None
+ key: Optional[str] = None
class RenameRequest(BaseModel):
title: str
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 29f9064..88d7967 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -18,6 +18,7 @@ interface Clip {
artwork_filename?: string | null
bpm?: number | null
key?: string | null
+ prompt_structure?: Record | null
created_at: string
}
diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx
index ebaa202..221dea4 100644
--- a/frontend/src/components/DetailPanel.tsx
+++ b/frontend/src/components/DetailPanel.tsx
@@ -13,6 +13,7 @@ interface Clip {
artwork_filename?: string | null
bpm?: number | null
key?: string | null
+ prompt_structure?: Record | null
created_at: string
}
@@ -280,6 +281,34 @@ export default function DetailPanel({ clip, isPlaying, currentClipId, onClose, o
+ {/* Structured Prompt Breakdown */}
+ {clip.prompt_structure && (
+
+
+
+ {[
+ { key: 'style', label: 'Style' },
+ { key: 'timbre', label: 'Timbre' },
+ { key: 'structure', label: 'Structure' },
+ { key: 'mood', label: 'Mood' },
+ { key: 'tempo', label: 'Tempo' },
+ { key: 'instrumentation', label: 'Instrumentation' },
+ { key: 'effects', label: 'Effects' },
+ { key: 'additional', label: 'Additional' },
+ ].map(({ key, label }) => {
+ const val = clip.prompt_structure?.[key]
+ if (!val) return null
+ return (
+
+ {label}:
+ {val}
+
+ )
+ })}
+
+
+ )}
+
{/* Status */}
diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx
index a2de56f..5a8f756 100644
--- a/frontend/src/components/Gallery.tsx
+++ b/frontend/src/components/Gallery.tsx
@@ -15,6 +15,7 @@ interface Clip {
artwork_filename?: string | null
bpm?: number | null
key?: string | null
+ prompt_structure?: Record
| null
created_at: string
}
diff --git a/frontend/src/components/Generator.tsx b/frontend/src/components/Generator.tsx
index 4028917..de1b95e 100644
--- a/frontend/src/components/Generator.tsx
+++ b/frontend/src/components/Generator.tsx
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
+import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor'
interface ModelInfo {
name: string
@@ -11,6 +12,8 @@ interface Props {
onGenerate: () => void
}
+type PromptMode = 'basic' | 'advanced'
+
export default function Generator({ onGenerate }: Props) {
const [prompt, setPrompt] = useState('')
const [model, setModel] = useState('lyria-3-clip-preview')
@@ -18,6 +21,8 @@ export default function Generator({ onGenerate }: Props) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [models, setModels] = useState>({})
+ const [promptMode, setPromptMode] = useState('basic')
+ const [structuredPrompt, setStructuredPrompt] = useState({})
useEffect(() => {
fetch('/api/models')
@@ -37,28 +42,40 @@ export default function Generator({ onGenerate }: Props) {
if (info) {
setDuration(info.default_duration)
}
+ // Reset to basic mode when switching to Clip Preview
+ if (newModel === 'lyria-3-clip-preview') {
+ setPromptMode('basic')
+ }
}
const currentModelInfo = models[model]
const maxDuration = currentModelInfo?.max_duration || 30
const minDuration = 5
+ const isPro = model === 'lyria-3-pro-preview'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!prompt.trim()) return
+ if (promptMode === 'basic' && !prompt.trim()) return
+ if (promptMode === 'advanced' && Object.values(structuredPrompt).every(v => !v?.trim())) return
setIsLoading(true)
setError('')
try {
+ const body: Record = {
+ prompt: prompt.trim(),
+ model,
+ duration,
+ }
+
+ if (promptMode === 'advanced' && isPro) {
+ body.prompt_structure = structuredPrompt
+ }
+
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- prompt: prompt.trim(),
- model,
- duration
- })
+ body: JSON.stringify(body)
})
if (!res.ok) {
@@ -78,6 +95,7 @@ export default function Generator({ onGenerate }: Props) {
}
setPrompt('')
+ setStructuredPrompt({})
onGenerate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
@@ -93,18 +111,7 @@ export default function Generator({ onGenerate }: Props) {