feat: Implement AI script generation for audiobook projects
This commit is contained in:
@@ -10,7 +10,7 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment } from '@/lib/api/audiobook'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest } from '@/lib/api/audiobook'
|
||||
import apiClient, { formatApiError, adminApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
@@ -352,11 +352,119 @@ function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onCl
|
||||
)
|
||||
}
|
||||
|
||||
const GENRE_OPTIONS = ['玄幻', '武侠', '仙侠', '现代言情', '都市', '悬疑', '科幻', '历史', '恐怖']
|
||||
|
||||
function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [genre, setGenre] = useState('玄幻')
|
||||
const [subgenre, setSubgenre] = useState('')
|
||||
const [premise, setPremise] = useState('')
|
||||
const [style, setStyle] = useState('')
|
||||
const [numCharacters, setNumCharacters] = useState(5)
|
||||
const [numChapters, setNumChapters] = useState(8)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const reset = () => {
|
||||
setTitle(''); setGenre('玄幻'); setSubgenre(''); setPremise(''); setStyle('')
|
||||
setNumCharacters(5); setNumChapters(8)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title) { toast.error('请输入作品标题'); return }
|
||||
if (!premise) { toast.error('请输入故事简介'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
await audiobookApi.createAIScript({
|
||||
title,
|
||||
genre,
|
||||
subgenre,
|
||||
premise,
|
||||
style,
|
||||
num_characters: numCharacters,
|
||||
num_chapters: numChapters,
|
||||
} as ScriptGenerationRequest)
|
||||
toast.success('AI剧本生成任务已创建')
|
||||
reset()
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
toast.error(formatApiError(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 生成剧本</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 pt-1">
|
||||
<Input placeholder="作品标题" value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="flex-1 h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={genre}
|
||||
onChange={e => setGenre(e.target.value)}
|
||||
>
|
||||
{GENRE_OPTIONS.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="子类型(可选,如:升级流)"
|
||||
value={subgenre}
|
||||
onChange={e => setSubgenre(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="故事简介(描述世界观、主角、核心冲突等)"
|
||||
rows={4}
|
||||
value={premise}
|
||||
onChange={e => setPremise(e.target.value)}
|
||||
/>
|
||||
<Input placeholder="写作风格(可选,如:热血、轻松幽默、黑暗沉郁)" value={style} onChange={e => setStyle(e.target.value)} />
|
||||
<div className="flex gap-3">
|
||||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs">角色数量(2-10)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={numCharacters}
|
||||
onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 flex flex-col gap-1 text-sm">
|
||||
<span className="text-muted-foreground text-xs">章节数量(2-30)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={2}
|
||||
max={30}
|
||||
value={numChapters}
|
||||
onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" onClick={() => { reset(); onClose() }} disabled={loading}>取消</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
||||
{loading ? '创建中...' : '生成剧本'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectListSidebar({
|
||||
projects,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onAIScript,
|
||||
onLLM,
|
||||
loading,
|
||||
collapsed,
|
||||
@@ -367,6 +475,7 @@ function ProjectListSidebar({
|
||||
selectedId: number | null
|
||||
onSelect: (id: number) => void
|
||||
onNew: () => void
|
||||
onAIScript: () => void
|
||||
onLLM: () => void
|
||||
loading: boolean
|
||||
collapsed: boolean
|
||||
@@ -395,6 +504,9 @@ function ProjectListSidebar({
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onNew} title={t('newProject')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -698,7 +810,11 @@ function CharactersPanel({
|
||||
onClick={onConfirm}
|
||||
disabled={loadingAction || editingCharId !== null}
|
||||
>
|
||||
{loadingAction ? t('projectCard.confirm.loading') : t('projectCard.confirm.button')}
|
||||
{loadingAction
|
||||
? t('projectCard.confirm.loading')
|
||||
: project.source_type === 'ai_generated'
|
||||
? t('projectCard.confirm.generateScript', '确认角色并生成剧本')
|
||||
: t('projectCard.confirm.button')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1100,6 +1216,7 @@ export default function Audiobook() {
|
||||
const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set())
|
||||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showAIScript, setShowAIScript] = useState(false)
|
||||
const [showLLM, setShowLLM] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
||||
@@ -1465,8 +1582,9 @@ export default function Audiobook() {
|
||||
setGeneratingChapterIndices(new Set())
|
||||
}
|
||||
}}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false) }}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false) }}
|
||||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false) }}
|
||||
loading={loading}
|
||||
collapsed={!sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(v => !v)}
|
||||
@@ -1477,6 +1595,7 @@ export default function Audiobook() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
|
||||
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||||
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||||
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
||||
{!selectedProject ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user