feat: Implement AI script generation for audiobook projects

This commit is contained in:
2026-03-13 11:29:56 +08:00
parent 444dcb8bcf
commit 35bf7a302a
14 changed files with 682 additions and 17 deletions

View File

@@ -1,5 +1,15 @@
import apiClient from '@/lib/api'
export interface ScriptGenerationRequest {
title: string
genre: string
subgenre?: string
premise: string
style?: string
num_characters?: number
num_chapters?: number
}
export interface AudiobookProject {
id: number
user_id: number
@@ -8,6 +18,7 @@ export interface AudiobookProject {
status: string
llm_model?: string
error_message?: string
script_config?: Record<string, unknown>
created_at: string
updated_at: string
}
@@ -58,6 +69,11 @@ export interface LLMConfig {
}
export const audiobookApi = {
createAIScript: async (data: ScriptGenerationRequest): Promise<AudiobookProject> => {
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script', data)
return response.data
},
createProject: async (data: {
title: string
source_type: string

View File

@@ -89,6 +89,7 @@
"confirm": {
"button": "Confirm Characters · Identify Chapters",
"generateScript": "Confirm Characters & Generate Script",
"loading": "Identifying...",
"chaptersRecognized": "Chapters identified"
},

View File

@@ -88,6 +88,7 @@
"confirm": {
"button": "キャラクター確認 · 章を識別",
"generateScript": "キャラクター確認 · 台本を生成",
"loading": "識別中...",
"chaptersRecognized": "章を識別しました"
},

View File

@@ -88,6 +88,7 @@
"confirm": {
"button": "캐릭터 확인 · 챕터 식별",
"generateScript": "캐릭터 확인 · 대본 생성",
"loading": "식별 중...",
"chaptersRecognized": "챕터가 식별되었습니다"
},

View File

@@ -92,6 +92,7 @@
"confirm": {
"button": "确认角色 · 识别章节",
"generateScript": "确认角色并生成剧本",
"loading": "识别中...",
"chaptersRecognized": "章节已识别"
},

View File

@@ -88,6 +88,7 @@
"confirm": {
"button": "確認角色 · 識別章節",
"generateScript": "確認角色並生成劇本",
"loading": "識別中...",
"chaptersRecognized": "章節已識別"
},

View File

@@ -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 />
) : (