Files
Canto/qwen3-tts-frontend/src/pages/Audiobook.tsx

2529 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Headphones, Clock, CheckCircle2, AlertCircle, CircleDot } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Navbar } from '@/components/Navbar'
import { AudioPlayer } from '@/components/AudioPlayer'
import { ChapterPlayer } from '@/components/ChapterPlayer'
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest, type NsfwScriptGenerationRequest } from '@/lib/api/audiobook'
import { RotateCcw } from 'lucide-react'
import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext'
function LazyAudioPlayer({ audioUrl, jobId, compact }: { audioUrl: string; jobId: number; compact?: boolean }) {
const [visible, setVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); observer.disconnect() } },
{ rootMargin: '120px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
return <div ref={ref}>{visible && <AudioPlayer audioUrl={audioUrl} jobId={jobId} compact={compact} />}</div>
}
const STATUS_CONFIG: Record<string, { Icon: React.ElementType; iconCls: string; cardBg: string; cardBgSel: string }> = {
pending: { Icon: Clock, iconCls: 'h-3 w-3 text-muted-foreground/40', cardBg: 'bg-background/50', cardBgSel: 'bg-muted' },
analyzing: { Icon: Loader2, iconCls: 'h-3 w-3 text-sky-500 animate-spin', cardBg: 'bg-sky-500/5', cardBgSel: 'bg-sky-500/20' },
characters_ready: { Icon: Loader2, iconCls: 'h-3 w-3 text-violet-500 animate-spin', cardBg: 'bg-violet-500/5', cardBgSel: 'bg-violet-500/20' },
parsing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
processing: { Icon: Loader2, iconCls: 'h-3 w-3 text-red-500 animate-spin', cardBg: 'bg-orange-500/5', cardBgSel: 'bg-orange-500/20' },
ready: { Icon: CircleDot, iconCls: 'h-3 w-3 text-cyan-500', cardBg: 'bg-cyan-500/5', cardBgSel: 'bg-cyan-500/20' },
generating: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-400 animate-spin', cardBg: 'bg-amber-400/5', cardBgSel: 'bg-amber-400/20' },
done: { Icon: CheckCircle2, iconCls: 'h-3 w-3 text-emerald-500', cardBg: 'bg-emerald-500/5', cardBgSel: 'bg-emerald-500/20' },
error: { Icon: AlertCircle, iconCls: 'h-3 w-3 text-destructive', cardBg: 'bg-destructive/5', cardBgSel: 'bg-destructive/20' },
}
function NsfwIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<circle cx="12" cy="12" r="10" />
<text x="12" y="15.5" textAnchor="middle" fontSize="8" fontWeight="900" fill="currentColor" stroke="none">18+</text>
</svg>
)
}
function SequentialPlayer({
segments,
projectId,
onPlayingChange,
}: {
segments: AudiobookSegment[]
projectId: number
onPlayingChange: (segmentId: number | null) => void
}) {
const { t } = useTranslation('audiobook')
const [displayIndex, setDisplayIndex] = useState<number | null>(null)
const [isLoading, setIsLoading] = useState(false)
const audioRef = useRef<HTMLAudioElement>(new Audio())
const blobUrlsRef = useRef<Record<number, string>>({})
const currentIndexRef = useRef<number | null>(null)
const doneSegments = segments.filter(s => s.status === 'done')
useEffect(() => {
const audio = audioRef.current
return () => {
audio.pause()
audio.src = ''
Object.values(blobUrlsRef.current).forEach(url => URL.revokeObjectURL(url))
}
}, [])
const stop = useCallback(() => {
audioRef.current.pause()
audioRef.current.src = ''
currentIndexRef.current = null
setDisplayIndex(null)
setIsLoading(false)
onPlayingChange(null)
}, [onPlayingChange])
const playSegment = useCallback(async (index: number) => {
if (index >= doneSegments.length) {
currentIndexRef.current = null
setDisplayIndex(null)
onPlayingChange(null)
return
}
const seg = doneSegments[index]
currentIndexRef.current = index
setDisplayIndex(index)
onPlayingChange(seg.id)
setIsLoading(true)
try {
if (!blobUrlsRef.current[seg.id]) {
const response = await apiClient.get(
audiobookApi.getSegmentAudioUrl(projectId, seg.id),
{ responseType: 'blob' }
)
blobUrlsRef.current[seg.id] = URL.createObjectURL(response.data)
}
const audio = audioRef.current
audio.src = blobUrlsRef.current[seg.id]
await audio.play()
} catch {
playSegment(index + 1)
} finally {
setIsLoading(false)
}
}, [doneSegments, projectId, onPlayingChange])
useEffect(() => {
const audio = audioRef.current
const handleEnded = () => {
if (currentIndexRef.current !== null) {
playSegment(currentIndexRef.current + 1)
}
}
audio.addEventListener('ended', handleEnded)
return () => audio.removeEventListener('ended', handleEnded)
}, [playSegment])
if (doneSegments.length === 0) return null
return (
<div className="flex items-center gap-1">
{displayIndex !== null ? (
<>
<Button size="xs" variant="ghost" className="text-red-500 hover:text-red-500 hover:bg-red-500/10" onClick={stop}>
<Square className="h-3 w-3 mr-1 fill-current" />{t('projectCard.sequential.stop')}
</Button>
<span className="text-xs text-muted-foreground">
{isLoading
? t('projectCard.sequential.loading')
: t('projectCard.sequential.progress', { current: displayIndex + 1, total: doneSegments.length })}
</span>
</>
) : (
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={() => playSegment(0)}>
<Play className="h-3 w-3 mr-1" />{t('projectCard.sequential.play', { count: doneSegments.length })}
</Button>
)}
</div>
)
}
function LogStream({ projectId, chapterId, active }: { projectId: number; chapterId?: number; active: boolean }) {
const [lines, setLines] = useState<string[]>([])
const [done, setDone] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!active) return
setLines([])
setDone(false)
const token = localStorage.getItem('token')
const apiBase = (import.meta.env.VITE_API_URL as string) || ''
const controller = new AbortController()
const chapterParam = chapterId !== undefined ? `?chapter_id=${chapterId}` : ''
fetch(`${apiBase}/audiobook/projects/${projectId}/logs${chapterParam}`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
}).then(async res => {
const reader = res.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: streamDone, value } = await reader.read()
if (streamDone) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data: ')) continue
try {
const msg = JSON.parse(line.slice(6))
if (msg.done) {
setDone(true)
} else if (typeof msg.index === 'number') {
setLines(prev => {
const next = [...prev]
next[msg.index] = msg.line
return next
})
}
} catch {}
}
}
}).catch(() => {})
return () => controller.abort()
}, [projectId, chapterId, active])
useEffect(() => {
const el = containerRef.current
if (el) el.scrollTop = el.scrollHeight
}, [lines])
if (lines.length === 0) return null
return (
<div ref={containerRef} className="rounded border border-green-900/40 bg-black/90 text-green-400 font-mono text-xs p-3 max-h-52 overflow-y-auto leading-relaxed">
{lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap">{line}</div>
))}
{!done && (
<span className="inline-block w-2 h-3 bg-green-400 animate-pulse ml-0.5 align-middle" />
)}
</div>
)
}
function LLMConfigDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation('audiobook')
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
const [loading, setLoading] = useState(false)
const [existing, setExisting] = useState<{ base_url?: string; model?: string; has_key: boolean } | null>(null)
useEffect(() => {
if (open) adminApi.getLLMConfig().then(setExisting).catch(() => {})
}, [open])
const handleSave = async () => {
if (!baseUrl || !apiKey || !model) {
toast.error(t('llmConfigPanel.incompleteError'))
return
}
setLoading(true)
try {
await adminApi.setLLMConfig({ base_url: baseUrl, api_key: apiKey, model })
toast.success(t('llmConfigPanel.savedSuccess'))
setApiKey('')
const updated = await adminApi.getLLMConfig()
setExisting(updated)
onClose()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('llmConfigPanel.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-1">
{existing && (
<div className="text-xs text-muted-foreground">
{t('llmConfigPanel.current', {
baseUrl: existing.base_url || t('llmConfigPanel.notSet'),
model: existing.model || t('llmConfigPanel.notSet'),
keyStatus: existing.has_key ? t('llmConfigPanel.hasKey') : t('llmConfigPanel.noKey'),
})}
</div>
)}
<Input placeholder="Base URL (e.g. https://api.openai.com/v1)" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} />
<Input placeholder="API Key" type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} />
<Input placeholder="Model (e.g. gpt-4o)" value={model} onChange={e => setModel(e.target.value)} />
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={onClose} disabled={loading}>
{t('cancel', { defaultValue: '取消' })}
</Button>
<Button size="sm" onClick={handleSave} disabled={loading}>
{loading ? t('llmConfigPanel.saving') : t('llmConfigPanel.save')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const { t } = useTranslation('audiobook')
const [title, setTitle] = useState('')
const [sourceType, setSourceType] = useState<'text' | 'epub'>('text')
const [text, setText] = useState('')
const [epubFile, setEpubFile] = useState<File | null>(null)
const [loading, setLoading] = useState(false)
const reset = () => { setTitle(''); setText(''); setEpubFile(null); setSourceType('text') }
const handleCreate = async () => {
if (!title) { toast.error(t('createPanel.titleRequired')); return }
if (sourceType === 'text' && !text) { toast.error(t('createPanel.textRequired')); return }
if (sourceType === 'epub' && !epubFile) { toast.error(t('createPanel.epubRequired')); return }
setLoading(true)
try {
if (sourceType === 'text') {
await audiobookApi.createProject({ title, source_type: 'text', source_text: text })
} else {
await audiobookApi.uploadEpub(title, epubFile!)
}
toast.success(t('createPanel.createdSuccess'))
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>{t('createPanel.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-1">
<Input placeholder={t('createPanel.titlePlaceholder')} value={title} onChange={e => setTitle(e.target.value)} />
<div className="flex gap-2">
<Button size="sm" variant={sourceType === 'text' ? 'default' : 'outline'} onClick={() => setSourceType('text')}>
{t('createPanel.pasteText')}
</Button>
<Button size="sm" variant={sourceType === 'epub' ? 'default' : 'outline'} onClick={() => setSourceType('epub')}>
{t('createPanel.uploadEpub')}
</Button>
</div>
{sourceType === 'text' && (
<Textarea placeholder={t('createPanel.textPlaceholder')} rows={6} value={text} onChange={e => setText(e.target.value)} />
)}
{sourceType === 'epub' && (
<Input type="file" accept=".epub" onChange={e => {
const file = e.target.files?.[0] || null
setEpubFile(file)
if (file && !title) setTitle(file.name.replace(/\.epub$/i, ''))
}} />
)}
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => { reset(); onClose() }} disabled={loading}>
{t('cancel', { defaultValue: '取消' })}
</Button>
<Button size="sm" onClick={handleCreate} disabled={loading}>
{loading ? t('createPanel.creating') : t('createPanel.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
interface SubgenreConfig {
protagonistTypes: string[]
tones: string[]
conflictScales: string[]
}
interface GenreGroup {
label: string
subgenres: Record<string, SubgenreConfig>
}
const GENRE_CONFIGS: Record<string, GenreGroup> = {
'玄幻': {
label: '玄幻',
subgenres: {
'高武玄幻': { protagonistTypes: ['热血少年', '落魄天才', '废材逆袭', '穿越者', '系统宿主', '天生废体'], tones: ['热血激情', '升级爽文', '黑暗血腥', '轻松搞笑', '史诗宏大'], conflictScales: ['门派争斗', '宗门战争', '诸天争霸', '个人成长', '复仇雪耻'] },
'修仙升级': { protagonistTypes: ['散修弟子', '资质平庸者', '天才修炼者', '转世神魂', '炼丹师', '剑修'], tones: ['清修飘逸', '热血激战', '奇幻神秘', '恩怨情仇', '历练成长'], conflictScales: ['渡劫飞升', '门派恩怨', '天道之争', '仙魔对决', '灵脉争夺'] },
'洪荒流': { protagonistTypes: ['洪荒神兽', '人族菜鸡', '太古妖族', '上古大能', '仙界强者', '混沌生灵'], tones: ['宏大史诗', '爽文热血', '阴谋算计', '轻松恶搞', '诸圣博弈'], conflictScales: ['洪荒争霸', '诸圣博弈', '天道争夺', '人族崛起', '封神大战'] },
'诸天万界': { protagonistTypes: ['穿梭者', '任务系统宿主', '时空旅行者', '多界强者', '轮回者'], tones: ['热血爽文', '智谋流', '搞笑无厘头', '严肃史诗', '全能碾压'], conflictScales: ['完成任务', '诸界争霸', '宇宙级威胁', '推翻天道', '救世传承'] },
'异世大陆': { protagonistTypes: ['穿越者', '异世贵族', '平民逆袭', '战神转世', '魔法天才', '剑神传人'], tones: ['热血冒险', '宫廷权谋', '温馨治愈', '黑暗复仇', '轻松奇幻'], conflictScales: ['王国争霸', '异族入侵', '魔兽泛滥', '政治阴谋', '个人成长'] },
},
},
'武侠': {
label: '武侠',
subgenres: {
'传统武侠': { protagonistTypes: ['江湖侠客', '名门弟子', '落魄世家子', '武学天才', '复仇者', '隐世高人'], tones: ['豪情壮志', '快意恩仇', '爱恨情仇', '正邪对决', '侠义热血'], conflictScales: ['门派之争', '江湖恩怨', '家仇国恨', '武林正邪', '武功秘籍争夺'] },
'奇幻武侠': { protagonistTypes: ['习得神功者', '寻宝冒险者', '侠义少年', '隐世高手', '机缘得道者'], tones: ['奇幻冒险', '武侠浪漫', '热血激战', '神秘探索', '奇遇成长'], conflictScales: ['神兵寻觅', '秘籍争夺', '奇遇成长', '救世任务', '阵法宝物'] },
'现代武侠': { protagonistTypes: ['隐居高手', '都市侠客', '特种兵出身者', '传承者', '武道研究者'], tones: ['现代都市感', '热血写实', '轻松幽默', '动作爽快', '家国情怀'], conflictScales: ['维护正义', '黑道对抗', '传承保护', '功夫切磋', '古武势力'] },
},
},
'仙侠': {
label: '仙侠',
subgenres: {
'东方修真': { protagonistTypes: ['凡人弟子', '仙门弟子', '落魄仙人', '天骄后裔', '妖族转化', '古神血脉'], tones: ['清逸飘渺', '热血激战', '唯美浪漫', '奇幻神秘', '历练成长'], conflictScales: ['渡劫飞升', '仙魔大战', '道侣情缘', '天机命运', '宗门争霸'] },
'剑仙传说': { protagonistTypes: ['剑道天才', '剑修散仙', '古剑传人', '剑灵融合者', '剑心通明者'], tones: ['飘逸洒脱', '剑意凛冽', '纵横江湖', '诗意浪漫', '独孤求败'], conflictScales: ['剑道证道', '至宝争夺', '仙凡之别', '剑与心境', '天下第一'] },
'斩妖除魔': { protagonistTypes: ['除魔使者', '天师传人', '驱魔术士', '正道大侠', '神选之人'], tones: ['热血正义', '悬疑恐怖', '神话奇幻', '黑暗压抑', '守护信念'], conflictScales: ['妖魔肆虐', '鬼怪作乱', '邪道崛起', '地狱封印', '天地失衡'] },
},
},
'现代言情': {
label: '现代言情',
subgenres: {
'甜宠': { protagonistTypes: ['普通女孩', '天真少女', '职场新人', '邻家女孩', '意外相遇者'], tones: ['甜蜜温馨', '欢笑爱情', '轻松浪漫', '治愈温暖', '撒糖日常'], conflictScales: ['误会化解', '追爱历程', '霸总求爱', '青涩初恋', '双向暗恋'] },
'虐恋': { protagonistTypes: ['坚强女主', '深情错付者', '误解中的恋人', '身份悬殊者', '宿命相遇者'], tones: ['虐心煎熬', '深情执着', '痛苦成长', '悲剧唯美', '破镜重圆'], conflictScales: ['身世误解', '爱而不得', '命运阻隔', '家族反对', '情感纠葛'] },
'婚姻生活': { protagonistTypes: ['已婚夫妻', '闪婚陌生人', '失婚重来者', '中年危机者', '再婚家庭成员'], tones: ['温馨现实', '矛盾磨合', '生活感悟', '烟火气息', '相濡以沫'], conflictScales: ['婚姻危机', '重建感情', '第三者风波', '家庭矛盾', '育儿分歧'] },
'青春校园': { protagonistTypes: ['高中生', '大学生', '校园社团成员', '学霸', '运动员'], tones: ['青涩懵懂', '阳光活力', '笑泪交织', '追梦热血', '暗恋悸动'], conflictScales: ['学习压力', '暗恋表白', '友情裂痕', '家庭变故', '比赛竞争'] },
},
},
'都市': {
label: '都市',
subgenres: {
'系统流': { protagonistTypes: ['普通小人物', '穿越重生者', '选中之人', '落魄天才', '被系统选中者'], tones: ['爽文升级', '轻松幽默', '热血励志', '全能碾压', '种田发展'], conflictScales: ['系统任务', '快速崛起', '实力碾压', '逆袭人生', '征服巅峰'] },
'商战': { protagonistTypes: ['商界精英', '白手起家者', '家族继承人', '职场新人', '商业奇才'], tones: ['紧张激烈', '智谋对抗', '励志奋斗', '尔虞我诈', '商业传奇'], conflictScales: ['商业竞争', '家族权斗', '职场博弈', '资本角力', '企业并购'] },
'重生逆袭': { protagonistTypes: ['重生者', '回到过去者', '带着记忆重来者', '复仇归来者', '先知者'], tones: ['爽文复仇', '轻松改变命运', '深情弥补', '步步为营', '大杀四方'], conflictScales: ['前世遗恨', '命运改写', '天才崛起', '恩仇清算', '守护挚爱'] },
'官场': { protagonistTypes: ['基层公务员', '仕途奋斗者', '清官强者', '改革者', '下乡干部'], tones: ['权谋斗争', '写实主义', '正义坚守', '仕途险恶', '为民请命'], conflictScales: ['官场倾轧', '腐败反贪', '仕途升迁', '民生为先', '政治博弈'] },
},
},
'悬疑': {
label: '悬疑',
subgenres: {
'推理侦探': { protagonistTypes: ['警探', '法医', '业余侦探', '记者', '侦探助手', '神探'], tones: ['烧脑推理', '扣人心弦', '悬念丛生', '冷静理性', '层层剥茧'], conflictScales: ['连环命案', '密室谜题', '真相揭露', '罪犯追踪', '系列悬案'] },
'犯罪惊悚': { protagonistTypes: ['警察', '罪犯视角', '受害者幸存者', '卧底', '特工'], tones: ['黑暗紧张', '写实血腥', '心理较量', '暗流涌动', '生死边缘'], conflictScales: ['犯罪追击', '身份揭露', '背叛与救赎', '生死博弈', '组织渗透'] },
'灵异恐怖': { protagonistTypes: ['普通人', '灵异体质者', '道士法师', '鬼怪', '民俗学者'], tones: ['恐怖诡异', '悬疑惊悚', '志怪神秘', '民俗风情', '冷汗直冒'], conflictScales: ['恶灵纠缠', '冤情揭露', '鬼怪作祟', '阴阳两界', '封印破解'] },
'心理悬疑': { protagonistTypes: ['心理咨询师', '精神科医生', '犯罪侧写师', '创伤幸存者', '不可靠叙述者'], tones: ['心理压迫', '黑暗深沉', '哲思反思', '人性剖析', '迷雾重重'], conflictScales: ['心魔挣脱', '人格分裂', '记忆迷失', '操控与反制', '真相解谜'] },
},
},
'科幻': {
label: '科幻',
subgenres: {
'星际战争': { protagonistTypes: ['星际舰长', '星际士兵', '精英飞行员', '星际难民', '外星协调者'], tones: ['史诗宏大', '战争残酷', '科技未来', '人性探索', '军事硬派'], conflictScales: ['星际战争', '殖民冲突', '物种灭绝威胁', '宇宙联邦争霸', '文明碰撞'] },
'末世废土': { protagonistTypes: ['末世幸存者', '变异人类', '反抗组织领袖', '科学家', '拾荒者'], tones: ['黑暗压抑', '生存挣扎', '希望微光', '残酷现实', '人性测试'], conflictScales: ['末世求生', '物资争夺', '反抗统治', '文明重建', '变异扩散'] },
'赛博朋克': { protagonistTypes: ['黑客', '赛博战士', '企业异见者', '增强人类', '数字游民'], tones: ['科技反乌托邦', '阴暗迷幻', '反体制', '霓虹暗夜', '数字哲学'], conflictScales: ['数据战争', '企业阴谋', '人机界限', '身份认同', '系统颠覆'] },
'时间旅行': { protagonistTypes: ['时间旅行者', '时间管理局成员', '意外穿越者', '历史修正者', '时间悖论受害者'], tones: ['烧脑悬疑', '蝴蝶效应', '时间悖论', '浪漫跨时代', '命运游戏'], conflictScales: ['历史修正', '时间线保护', '因果悖论', '多重未来', '时间战争'] },
'人工智能': { protagonistTypes: ['AI研究员', '觉醒AI', '人机协作者', '反AI组织成员', '数字意识体'], tones: ['哲学反思', '科技惊悚', '情感探索', '伦理困境', '奇点到来'], conflictScales: ['AI觉醒', '人机战争', '意识复制', '伦理边界', '数字文明'] },
},
},
'历史': {
label: '历史',
subgenres: {
'架空历史': { protagonistTypes: ['穿越者', '改变历史者', '默默无闻小人物', '穿越官员', '现代人'], tones: ['宏大史诗', '权谋斗争', '家国情怀', '轻松穿越', '历史重构'], conflictScales: ['朝代更迭', '权力争夺', '外族入侵', '历史改变', '改革维新'] },
'宫廷斗争': { protagonistTypes: ['妃嫔', '皇子', '太监谋士', '宫女逆袭者', '皇后'], tones: ['心机深沉', '权谋博弈', '悲情凄美', '步步惊心', '宫廷浮沉'], conflictScales: ['后宫争宠', '皇位争夺', '废立之争', '家族联姻', '宫廷政变'] },
'战争史诗': { protagonistTypes: ['将领', '士兵', '谋士', '王侯', '战地医者'], tones: ['悲壮雄浑', '铁血热血', '英雄主义', '战争残酷', '家国大义'], conflictScales: ['国家存亡', '统一战争', '抗击外敌', '乱世争雄', '以少胜多'] },
'历史正剧': { protagonistTypes: ['历史名人', '草根智者', '政治家', '民间义士', '文人墨客'], tones: ['厚重写实', '人文感悟', '历史还原', '时代悲歌', '英雄气节'], conflictScales: ['时代变革', '个人命运', '历史洪流', '民族大义', '文化传承'] },
},
},
'恐怖': {
label: '恐怖',
subgenres: {
'都市灵异': { protagonistTypes: ['普通市民', '灵异体质者', '鬼怪猎人', '民俗研究者', '意外卷入者'], tones: ['惊悚诡异', '都市传说', '黑暗压抑', '悬疑恐惧', '无处可逃'], conflictScales: ['都市怪谈', '恶灵作祟', '冤魂纠缠', '超自然事件', '禁忌揭秘'] },
'克苏鲁': { protagonistTypes: ['学者研究者', '猎奇者', '受选者', '意志坚强者', '古神信徒'], tones: ['宇宙级恐惧', '理智崩溃', '末日预感', '深渊凝视', '存在主义恐怖'], conflictScales: ['禁忌召唤', '理性崩溃', '古神苏醒', '深渊凝视', '人类渺小'] },
'鬼屋探险': { protagonistTypes: ['探险者', '灵媒', '记者', '大胆学生', '真相追寻者'], tones: ['极度紧张', '恐怖刺激', '生死挣扎', '诡异氛围', '密室求生'], conflictScales: ['怨灵诅咒', '脱离险境', '真相揭露', '诡异机关', '黑暗守护者'] },
'丧尸末日': { protagonistTypes: ['末日幸存者', '军人', '科学家', '普通家庭', '幸运儿'], tones: ['生存惊悚', '人性黑暗', '绝望与希望', '末日互助', '道德崩塌'], conflictScales: ['丧尸追击', '幸存者冲突', '疫苗寻找', '安全区争夺', '文明坚守'] },
},
},
'Fantasy': {
label: 'Fantasy',
subgenres: {
'High Fantasy': { protagonistTypes: ['Chosen One', 'Magic User', 'Knight/Warrior', 'Royal Heir', 'Common Hero', 'Prophesied One'], tones: ['Epic', 'Heroic', 'Noble', 'Mythic', 'Adventure'], conflictScales: ['Personal Quest', 'Kingdom-wide', 'World-saving', 'Good vs Evil', 'Political Intrigue'] },
'Dark Fantasy': { protagonistTypes: ['Antihero', 'Cursed Individual', 'Monster Hunter', 'Corrupted Noble', 'Reluctant Chosen One', 'Morally Gray Wizard'], tones: ['Grim', 'Morally Ambiguous', 'Horror-tinged', 'Psychological', 'Brutal'], conflictScales: ['Personal Survival', 'Moral Choice', 'Power Corruption', 'Existential Horror', 'Societal Decay'] },
'Urban Fantasy': { protagonistTypes: ['Magical Detective', 'Hidden World Guardian', 'Modern Witch/Wizard', 'Supernatural Creature', 'Normal Person Discovered', 'Magic Shop Owner'], tones: ['Modern', 'Mysterious', 'Action-packed', 'Detective Noir', 'Secret World'], conflictScales: ['Personal', 'City-wide', 'Hidden Society', 'Supernatural Politics', 'World-threatening'] },
'Sword and Sorcery': { protagonistTypes: ['Warrior', 'Rogue', 'Barbarian', 'Mercenary', 'Wandering Wizard', 'Treasure Hunter'], tones: ['Adventurous', 'Gritty', 'Action-focused', 'Pulp', 'Personal'], conflictScales: ['Personal Gain', 'Adventure', 'Survival', 'Quest', 'Local Threat'] },
'Mythic Fantasy': { protagonistTypes: ['Demigod', 'Legendary Hero', 'Oracle', 'Divine Champion', 'Monster Slayer', 'Mythical Creature'], tones: ['Legendary', 'Epic', 'Mythological', 'Divine', 'Larger than Life'], conflictScales: ['Divine Politics', 'Legendary Quests', 'Fate of Gods', 'Mythic Prophecy', 'World-shaping'] },
'Fairy Tale': { protagonistTypes: ['Innocent Hero', 'Clever Trickster', 'Transformed Being', 'Royal Figure', 'Magical Helper', 'Common Person'], tones: ['Whimsical', 'Moral', 'Magical', 'Traditional', 'Transformative'], conflictScales: ['Personal Journey', 'Moral Test', 'Magical Challenge', 'Kingdom Fate', 'Breaking Curses'] },
},
},
'Sci-Fi': {
label: 'Sci-Fi',
subgenres: {
'Space Opera': { protagonistTypes: ['Military Officer', 'Merchant Captain', 'Explorer', 'Diplomat', 'Rebel Leader', 'Imperial Noble'], tones: ['Optimistic', 'Dark', 'Political', 'Adventure-focused', 'Character-driven'], conflictScales: ['Personal', 'Planetary', 'Interstellar', 'Galactic', 'Species Survival'] },
'Cyberpunk': { protagonistTypes: ['Hacker', 'Street Mercenary', 'Corporate Defector', 'AI Researcher', 'Underground Activist', 'Augmented Human'], tones: ['Noir', 'Gritty', 'Anti-establishment', 'Dystopian', 'Tech-noir'], conflictScales: ['Personal', 'Street Level', 'Corporate', 'Systemic', 'Digital'] },
'Post-Apocalyptic': { protagonistTypes: ['Survivor', 'Wasteland Warrior', 'Community Leader', 'Scavenger', 'Medic', 'Former Military'], tones: ['Grim', 'Survival-focused', 'Hope in Darkness', 'Brutal', 'Revolutionary'], conflictScales: ['Personal Survival', 'Group Survival', 'Resource Control', 'Territory', 'Rebuilding Society'] },
'Hard Sci-Fi': { protagonistTypes: ['Scientist', 'Engineer', 'Astronaut', 'Research Team Leader', 'AI Researcher', 'Technical Specialist'], tones: ['Technical', 'Philosophical', 'Discovery-focused', 'Methodical', 'Realistic'], conflictScales: ['Personal', 'Technical', 'Scientific Discovery', 'Environmental', 'Existential'] },
'Biopunk': { protagonistTypes: ['Genetic Engineer', 'Modified Human', 'Underground Scientist', 'Corporate Whistleblower', 'Bio-hacker', 'Test Subject'], tones: ['Body Horror', 'Ethical Drama', 'Scientific', 'Anti-corporate', 'Transformative'], conflictScales: ['Personal', 'Medical', 'Ethical', 'Corporate', 'Species-wide'] },
'Time Travel': { protagonistTypes: ['Time Agent', 'Accidental Traveler', 'Historical Researcher', 'Timeline Guardian', 'Temporal Engineer'], tones: ['Complex', 'Mysterious', 'Philosophical', 'Adventure', 'Causality-focused'], conflictScales: ['Personal', 'Historical', 'Timeline Preservation', 'Paradox Prevention', 'Multi-temporal'] },
},
},
'Mystery': {
label: 'Mystery',
subgenres: {
'Cozy Mystery': { protagonistTypes: ['Amateur Detective', 'Librarian', 'Shop Owner', 'Retired Professional', 'Local Resident', 'Hobby Enthusiast'], tones: ['Gentle', 'Puzzle-focused', 'Community-centered', 'Cozy', 'Character-driven'], conflictScales: ['Personal Mystery', 'Community Secret', 'Local Crime', 'Family Mystery', 'Historical Puzzle'] },
'Police Procedural': { protagonistTypes: ['Police Detective', 'Forensic Specialist', 'Police Captain', 'Crime Scene Investigator', 'FBI Agent'], tones: ['Realistic', 'Procedural', 'Professional', 'Methodical', 'Team-focused'], conflictScales: ['Individual Cases', 'Serial Crimes', 'Organized Crime', 'Corruption', 'Major Investigations'] },
'Hard-boiled': { protagonistTypes: ['Private Detective', 'Ex-Cop', 'Cynical Investigator', 'Tough Guy', 'Street-smart Detective', 'Noir Hero'], tones: ['Noir', 'Cynical', 'Gritty', 'Dark', 'Atmospheric'], conflictScales: ['Personal Cases', 'Corruption', 'Urban Crime', 'Moral Choices', 'Survival'] },
'Psychological Mystery': { protagonistTypes: ['Psychologist', 'Troubled Detective', 'Mental Health Professional', 'Unreliable Narrator', 'Psychological Profiler'], tones: ['Psychological', 'Introspective', 'Mind-bending', 'Complex', 'Character-focused'], conflictScales: ['Mental Mysteries', 'Psychological Crimes', 'Identity Issues', 'Memory Problems', 'Perception Puzzles'] },
'Historical Mystery': { protagonistTypes: ['Period Detective', 'Historical Figure', 'Scholar', 'Period Professional', 'Aristocrat', 'Common Person'], tones: ['Historical', 'Authentic', 'Period-appropriate', 'Cultural', 'Educational'], conflictScales: ['Personal Mysteries', 'Historical Events', 'Period Crimes', 'Social Issues', 'Political Intrigue'] },
},
},
'Horror': {
label: 'Horror',
subgenres: {
'Gothic Horror': { protagonistTypes: ['Haunted Individual', 'Investigator', 'Innocent Victim', 'Cursed Person', 'Gothic Hero', 'Tormented Soul'], tones: ['Atmospheric', 'Psychological', 'Brooding', 'Mysterious', 'Melancholic'], conflictScales: ['Personal Haunting', 'Family Curse', 'Supernatural Threat', 'Psychological Terror', 'Ancient Evil'] },
'Cosmic Horror': { protagonistTypes: ['Academic Researcher', 'Occult Investigator', 'Unwitting Scholar', 'Cosmic Witness', 'Doomed Explorer', 'Sanity-threatened Individual'], tones: ['Existential', 'Unknowable', 'Cosmic', 'Dread-filled', 'Mind-breaking'], conflictScales: ['Cosmic Revelation', 'Sanity Destruction', 'Reality Breakdown', 'Ancient Awakening', 'Existential Horror'] },
'Psychological Horror': { protagonistTypes: ['Unreliable Narrator', 'Mentally Unstable', 'Paranoid Individual', 'Trauma Victim', 'Isolated Person'], tones: ['Psychological', 'Disturbing', 'Mind-bending', 'Paranoid', 'Introspective'], conflictScales: ['Mental Breakdown', 'Reality Distortion', 'Psychological Manipulation', 'Internal Terror', 'Sanity Loss'] },
'Supernatural Horror': { protagonistTypes: ['Paranormal Investigator', 'Haunted Individual', 'Psychic Medium', 'Skeptical Researcher', 'Spiritual Warrior', 'Innocent Victim'], tones: ['Supernatural', 'Eerie', 'Spiritual', 'Otherworldly', 'Paranormal'], conflictScales: ['Ghostly Haunting', 'Demonic Possession', 'Spiritual Warfare', 'Paranormal Investigation', 'Supernatural Threat'] },
'Slasher Horror': { protagonistTypes: ['Final Girl', 'Survivor', 'Potential Victim', 'Group Member', 'Resourceful Fighter'], tones: ['Suspenseful', 'Violent', 'Survival-focused', 'Intense', 'Action-horror'], conflictScales: ['Survival', 'Killer Hunt', 'Group Elimination', 'Escape Attempt', 'Final Confrontation'] },
},
},
'Thriller': {
label: 'Thriller',
subgenres: {
'Espionage Thriller': { protagonistTypes: ['Secret Agent', 'Intelligence Officer', 'Double Agent', 'Spy Handler', 'Undercover Operative', 'Government Analyst'], tones: ['Sophisticated', 'International', 'High-stakes', 'Political', 'Covert'], conflictScales: ['International Conspiracy', 'Government Secrets', 'Spy Networks', 'National Security', 'Global Politics'] },
'Psychological Thriller': { protagonistTypes: ['Psychologist', 'Mentally Unstable', 'Manipulation Victim', 'Paranoid Individual', 'Mind Game Player', 'Psychological Profiler'], tones: ['Psychological', 'Mind-bending', 'Paranoid', 'Manipulative', 'Disturbing'], conflictScales: ['Mental Manipulation', 'Psychological Torture', 'Mind Control', 'Paranoid Delusions', 'Reality Breakdown'] },
'Action Thriller': { protagonistTypes: ['Action Hero', 'Special Forces', 'Mercenary', 'Bodyguard', 'Martial Artist'], tones: ['High-energy', 'Action-packed', 'Adrenaline-fueled', 'Physical', 'Fast-paced'], conflictScales: ['Physical Confrontation', 'High-speed Chases', 'Combat Situations', 'Rescue Missions', 'Survival Scenarios'] },
'Legal Thriller': { protagonistTypes: ['Lawyer', 'Judge', 'Legal Investigator', 'Prosecutor', 'Defense Attorney', 'Legal Whistleblower'], tones: ['Legal', 'Procedural', 'Justice-focused', 'Institutional', 'Courtroom-driven'], conflictScales: ['Legal Conspiracy', 'Courtroom Drama', 'Justice Corruption', 'Legal Cover-up', 'Judicial Manipulation'] },
'Techno-Thriller': { protagonistTypes: ['Computer Expert', 'Cyber Security Specialist', 'Tech Entrepreneur', 'Hacker', 'Scientist', 'Digital Investigator'], tones: ['High-tech', 'Fast-paced', 'Technical', 'Futuristic', 'Digital'], conflictScales: ['Cyber Attacks', 'Technological Threats', 'Digital Warfare', 'Scientific Disasters', 'Tech Conspiracies'] },
'Political Thriller': { protagonistTypes: ['Politician', 'Journalist', 'Government Insider', 'Whistleblower', 'Political Aide', 'Investigative Reporter'], tones: ['Political', 'Investigative', 'Institutional', 'Conspiratorial', 'Power-focused'], conflictScales: ['Political Corruption', 'Government Conspiracy', 'Electoral Manipulation', 'Institutional Cover-up', 'Power Abuse'] },
},
},
}
function Chip({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
selected
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:border-primary/50 hover:text-foreground'
}`}
>
{label}
</button>
)
}
function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('')
const [genre, setGenre] = useState('')
const [subgenre, setSubgenre] = useState('')
const [protagonistType, setProtagonistType] = useState('')
const [tone, setTone] = useState('')
const [conflictScale, setConflictScale] = useState('')
const [numCharacters, setNumCharacters] = useState(5)
const [numChapters, setNumChapters] = useState(8)
const [synopsis, setSynopsis] = useState('')
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
const [loading, setLoading] = useState(false)
const genreKeys = Object.keys(GENRE_CONFIGS)
const subgenreKeys = genre ? Object.keys(GENRE_CONFIGS[genre]?.subgenres ?? {}) : []
const subgenreConfig = (genre && subgenre) ? GENRE_CONFIGS[genre]?.subgenres[subgenre] : null
const reset = () => {
setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
setConflictScale(''); setNumCharacters(5); setNumChapters(8); setSynopsis('')
}
const handleGenreSelect = (g: string) => {
setGenre(g); setSubgenre(''); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
}
const handleSubgenreSelect = (s: string) => {
setSubgenre(s); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
}
const handleGenerateSynopsis = async () => {
if (!title) { toast.error('请输入作品标题'); return }
if (!genre) { toast.error('请选择故事类型'); return }
setGeneratingSynopsis(true)
try {
const result = await audiobookApi.generateSynopsis({
title,
genre: subgenre ? `${genre} - ${subgenre}` : genre,
subgenre,
protagonist_type: protagonistType,
tone,
conflict_scale: conflictScale,
num_characters: numCharacters,
num_chapters: numChapters,
})
setSynopsis(result)
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setGeneratingSynopsis(false)
}
}
const handleCreate = async () => {
if (!title) { toast.error('请输入作品标题'); return }
if (!synopsis) { toast.error('请先生成故事简介'); return }
setLoading(true)
try {
await audiobookApi.createAIScript({
title,
genre: subgenre ? `${genre} - ${subgenre}` : genre,
subgenre,
premise: synopsis,
style: tone,
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-2xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>AI </DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{genreKeys.map(g => (
<Chip key={g} label={GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
))}
</div>
</div>
{genre && subgenreKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreKeys.map(s => (
<Chip key={s} label={s} selected={subgenre === s} onClick={() => handleSubgenreSelect(s)} />
))}
</div>
</div>
)}
{subgenreConfig && (
<>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.protagonistTypes.map(p => (
<Chip key={p} label={p} selected={protagonistType === p} onClick={() => setProtagonistType(protagonistType === p ? '' : p)} />
))}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.tones.map(t => (
<Chip key={t} label={t} selected={tone === t} onClick={() => setTone(tone === t ? '' : t)} />
))}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.conflictScales.map(c => (
<Chip key={c} label={c} selected={conflictScale === c} onClick={() => setConflictScale(conflictScale === c ? '' : c)} />
))}
</div>
</div>
</>
)}
<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">
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={!genre || generatingSynopsis}>
{generatingSynopsis ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{generatingSynopsis ? '生成中...' : '生成故事简介'}
</Button>
</div>
{synopsis && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<Textarea rows={6} value={synopsis} onChange={e => setSynopsis(e.target.value)} className="text-sm" />
</div>
)}
</div>
<div className="flex justify-between gap-2 pt-3 shrink-0 border-t">
<Button size="sm" variant="ghost" onClick={() => { reset(); onClose() }} disabled={loading}></Button>
<div className="flex gap-2">
{synopsis && (
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={generatingSynopsis}>
<RotateCcw className="h-3 w-3 mr-1" />
</Button>
)}
<Button size="sm" onClick={handleCreate} disabled={loading || !synopsis || !title}>
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{loading ? '创建中...' : '生成剧本'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
const NSFW_GENRE_CONFIGS: Record<string, { label: string; subgenres: Record<string, { protagonistTypes: string[]; tones: string[]; conflictScales: string[] }> }> = {
'都市成人': {
label: '都市成人',
subgenres: {
'办公室禁忌': { protagonistTypes: ['职场白领', '高管下属', '同事关系', '猎头顾问'], tones: ['压抑克制', '暧昧升温', '禁忌诱惑', '情欲失控'], conflictScales: ['办公室恩怨', '权力游戏', '职场暗战', '情感纠葛'] },
'豪门虐恋': { protagonistTypes: ['普通女孩', '豪门公子', '隐婚妻子', '商业联姻'], tones: ['痴缠虐恋', '甜蜜救赎', '冷酷霸总', '深情告白'], conflictScales: ['门第悬殊', '家族势力', '商业角力', '情感博弈'] },
'合约婚姻': { protagonistTypes: ['合约双方', '假戏真做', '情感觉醒', '身份反转'], tones: ['冷漠开局', '渐入佳境', '情不自禁', '破镜重圆'], conflictScales: ['合约终止', '真心暴露', '外敌干扰', '家族干涉'] },
'姐弟恋': { protagonistTypes: ['成熟女性', '年轻男性', '职场前辈', '学生社会人'], tones: ['成熟风情', '反差萌甜', '年龄压制', '互相依赖'], conflictScales: ['年龄差距', '外界眼光', '情感成熟度', '未来规划'] },
'高干文': { protagonistTypes: ['官员子弟', '平民女主', '政商联姻', '秘密保护'], tones: ['权力压迫', '低调奢华', '隐秘情感', '强势占有'], conflictScales: ['权力博弈', '政治婚姻', '身份暴露', '家族对决'] },
},
},
'修仙双修': {
label: '修仙双修',
subgenres: {
'双修秘法': { protagonistTypes: ['天才弟子', '神秘师尊', '炉鼎逆袭', '道侣缘定'], tones: ['禁忌师徒', '双修悟道', '情欲修炼', '天道孽缘'], conflictScales: ['门派规矩', '天道考验', '魔道诱惑', '飞升之争'] },
'仙尊宠妻': { protagonistTypes: ['无名小修', '万年仙尊', '女弟子', '神女转世'], tones: ['宠溺无边', '万年等待', '强强联手', '情深似海'], conflictScales: ['外敌窥伺', '仙界规则', '劫数将临', '情缘注定'] },
'妖族后宫': { protagonistTypes: ['妖王', '人族女修', '混血妖神', '后宫妃嫔'], tones: ['妖冶风情', '强势占有', '蛊惑人心', '情孽难断'], conflictScales: ['人妖对立', '族群战争', '后宫争宠', '妖皇之位'] },
'天骄配天娇': { protagonistTypes: ['第一天骄', '绝世女主', '双强对决', '势均力敌'], tones: ['互相较劲', '惺惺相惜', '强强碰撞', '双向暗恋'], conflictScales: ['修炼竞争', '争夺机缘', '外敌来犯', '道路抉择'] },
},
},
'奇幻异世': {
label: '奇幻异世',
subgenres: {
'兽人联姻': { protagonistTypes: ['穿越女主', '兽人王者', '人兽混血', '部落首领'], tones: ['野性粗犷', '温柔呵护', '征服臣服', '异族情缘'], conflictScales: ['种族融合', '部落战争', '血脉觉醒', '异世生存'] },
'魔君圣女': { protagonistTypes: ['圣女祭司', '堕落魔君', '神魔对立', '光暗守护者'], tones: ['圣洁污染', '光暗交融', '禁忌之恋', '救赎与沉沦'], conflictScales: ['神魔大战', '命运抗争', '信仰崩塌', '世界毁灭'] },
'穿越迷情': { protagonistTypes: ['现代女性', '古代君王', '异世界人物', '时空旅者'], tones: ['时空错位', '身份迷失', '古今碰撞', '情不知所起'], conflictScales: ['回归困境', '历史改变', '时空悖论', '情感牵绊'] },
'契约恋人': { protagonistTypes: ['契约双方', '灵魂契约', '主仆关系', '命运绑定'], tones: ['被迫相处', '情感萌生', '契约升温', '破除枷锁'], conflictScales: ['契约限制', '灵魂危机', '外界阻碍', '真心表达'] },
},
},
'历史宫廷': {
label: '历史宫廷',
subgenres: {
'帝王宠妃': { protagonistTypes: ['寒门女子', '九五之尊', '独宠专房', '贵妃娘娘'], tones: ['专宠霸道', '龙恩浩荡', '后宫独步', '帝心难测'], conflictScales: ['后宫争宠', '前朝牵制', '子嗣之争', '外戚干政'] },
'宫廷秘史': { protagonistTypes: ['宫廷暗卫', '妃嫔密探', '皇室隐秘', '历史见证者'], tones: ['权谋算计', '忠义两难', '隐秘情愫', '乱世真情'], conflictScales: ['宫廷政变', '皇位继承', '异国入侵', '家族倾轧'] },
'宅斗宫斗': { protagonistTypes: ['嫡女庶女', '正妻侍妾', '宫女妃嫔', '世家主母'], tones: ['心机算尽', '步步为营', '白莲花黑化', '大女主觉醒'], conflictScales: ['争夺家产', '嫁妆之战', '子嗣竞争', '家族延续'] },
'后宫权谋': { protagonistTypes: ['权倾后宫者', '新晋宠妃', '太后皇后', '谋士幕僚'], tones: ['深宫寂寞', '权力欲望', '情与利的博弈', '最终胜者独孤'], conflictScales: ['废立皇后', '皇储之争', '派系对立', '外朝联动'] },
},
},
'现代激情': {
label: '现代激情',
subgenres: {
'一夜情缘': { protagonistTypes: ['陌生男女', '意外邂逅', '酒后乱性', '重逢旧恋'], tones: ['激情一夜', '难以忘怀', '意外怀孕', '命运纠缠'], conflictScales: ['身份悬殊', '情感纠缠', '意外结果', '关系定义'] },
'前任回归': { protagonistTypes: ['前男友女友', '新欢旧爱', '情感疗愈者', '纠缠不清'], tones: ['旧情复燃', '爱恨交织', '放不下的执念', '破镜能否重圆'], conflictScales: ['感情空白期', '第三者介入', '过去伤害', '信任重建'] },
'欲望都市': { protagonistTypes: ['都市白领', '成功商人', '独立女性', '富二代'], tones: ['纸醉金迷', '欲望横流', '都市孤独', '情欲游戏'], conflictScales: ['金钱利益', '情感空虚', '道德底线', '真爱追寻'] },
'豪门宠婚': { protagonistTypes: ['灰姑娘女主', '豪门单身汉', '闪婚闪恋', '宠妻狂魔'], tones: ['宠溺日常', '霸道甜蜜', '嫉妒吃醋', '甜到发腻'], conflictScales: ['豪门规则', '家族考验', '外来破坏', '证明真爱'] },
},
},
}
function NSFWScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('')
const [genre, setGenre] = useState('')
const [subgenre, setSubgenre] = useState('')
const [protagonistType, setProtagonistType] = useState('')
const [tone, setTone] = useState('')
const [conflictScale, setConflictScale] = useState('')
const [numCharacters, setNumCharacters] = useState(5)
const [numChapters, setNumChapters] = useState(8)
const [violenceLevel, setViolenceLevel] = useState(0)
const [eroticismLevel, setEroticismLevel] = useState(5)
const [synopsis, setSynopsis] = useState('')
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
const [loading, setLoading] = useState(false)
const genreKeys = Object.keys(NSFW_GENRE_CONFIGS)
const subgenreKeys = genre ? Object.keys(NSFW_GENRE_CONFIGS[genre]?.subgenres ?? {}) : []
const subgenreConfig = (genre && subgenre) ? NSFW_GENRE_CONFIGS[genre]?.subgenres[subgenre] : null
const reset = () => {
setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
setConflictScale(''); setNumCharacters(5); setNumChapters(8); setViolenceLevel(0); setEroticismLevel(5); setSynopsis('')
}
const handleGenreSelect = (g: string) => {
setGenre(g); setSubgenre(''); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
}
const handleSubgenreSelect = (s: string) => {
setSubgenre(s); setProtagonistType(''); setTone(''); setConflictScale(''); setSynopsis('')
}
const handleGenerateSynopsis = async () => {
if (!title) { toast.error('请输入作品标题'); return }
if (!genre) { toast.error('请选择故事类型'); return }
setGeneratingSynopsis(true)
try {
const result = await audiobookApi.generateNsfwSynopsis({
title,
genre: subgenre ? `${genre} - ${subgenre}` : genre,
subgenre,
protagonist_type: protagonistType,
tone,
conflict_scale: conflictScale,
num_characters: numCharacters,
num_chapters: numChapters,
violence_level: violenceLevel,
eroticism_level: eroticismLevel,
})
setSynopsis(result)
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setGeneratingSynopsis(false)
}
}
const handleCreate = async () => {
if (!title) { toast.error('请输入作品标题'); return }
if (!synopsis) { toast.error('请先生成故事简介'); return }
setLoading(true)
try {
await audiobookApi.createNsfwScript({
title,
genre: subgenre ? `${genre} - ${subgenre}` : genre,
subgenre,
premise: synopsis,
style: tone,
num_characters: numCharacters,
num_chapters: numChapters,
violence_level: violenceLevel,
eroticism_level: eroticismLevel,
} as NsfwScriptGenerationRequest)
toast.success('NSFW剧本生成任务已创建')
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-2xl max-h-[90vh] flex flex-col">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<NsfwIcon className="h-4 w-4 text-red-500" />
NSFW
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{genreKeys.map(g => (
<Chip key={g} label={NSFW_GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
))}
</div>
</div>
{genre && subgenreKeys.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreKeys.map(s => (
<Chip key={s} label={s} selected={subgenre === s} onClick={() => handleSubgenreSelect(s)} />
))}
</div>
</div>
)}
{subgenreConfig && (
<>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.protagonistTypes.map(p => (
<Chip key={p} label={p} selected={protagonistType === p} onClick={() => setProtagonistType(protagonistType === p ? '' : p)} />
))}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.tones.map(t => (
<Chip key={t} label={t} selected={tone === t} onClick={() => setTone(tone === t ? '' : t)} />
))}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<div className="flex flex-wrap gap-1.5">
{subgenreConfig.conflictScales.map(c => (
<Chip key={c} label={c} selected={conflictScale === c} onClick={() => setConflictScale(conflictScale === c ? '' : c)} />
))}
</div>
</div>
</>
)}
<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 gap-3">
<label className="flex-1 flex flex-col gap-1 text-sm">
<span className="text-muted-foreground text-xs">{violenceLevel}/10</span>
<input type="range" min={0} max={10} step={1} value={violenceLevel} onChange={e => setViolenceLevel(Number(e.target.value))} className="w-full accent-red-500" />
</label>
<label className="flex-1 flex flex-col gap-1 text-sm">
<span className="text-muted-foreground text-xs">{eroticismLevel}/10</span>
<input type="range" min={0} max={10} step={1} value={eroticismLevel} onChange={e => setEroticismLevel(Number(e.target.value))} className="w-full accent-pink-500" />
</label>
</div>
<div className="flex justify-end">
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={!genre || generatingSynopsis}>
{generatingSynopsis ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{generatingSynopsis ? '生成中...' : '生成故事简介'}
</Button>
</div>
{synopsis && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground"></p>
<Textarea rows={6} value={synopsis} onChange={e => setSynopsis(e.target.value)} className="text-sm" />
</div>
)}
</div>
<div className="flex justify-between gap-2 pt-3 shrink-0 border-t">
<Button size="sm" variant="ghost" onClick={() => { reset(); onClose() }} disabled={loading}></Button>
<div className="flex gap-2">
{synopsis && (
<Button size="sm" variant="outline" onClick={handleGenerateSynopsis} disabled={generatingSynopsis}>
<RotateCcw className="h-3 w-3 mr-1" />
</Button>
)}
<Button size="sm" onClick={handleCreate} disabled={loading || !synopsis || !title}>
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{loading ? '创建中...' : '生成剧本'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function ContinueScriptDialog({ open, onClose, onConfirm }: { open: boolean; onClose: () => void; onConfirm: (n: number) => Promise<void> }) {
const { t } = useTranslation('audiobook')
const [count, setCount] = useState(4)
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
setLoading(true)
try {
await onConfirm(count)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t('projectCard.continueScriptDialog.title')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-muted-foreground text-xs">{t('projectCard.continueScriptDialog.label')}</span>
<Input
type="number" min={1} max={20} value={count}
onChange={e => setCount(Math.min(20, Math.max(1, Number(e.target.value))))}
/>
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button size="sm" variant="ghost" onClick={onClose} disabled={loading}>{t('projectCard.segments.cancel')}</Button>
<Button size="sm" onClick={handleSubmit} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{loading ? t('projectCard.continueScriptDialog.starting') : t('projectCard.continueScriptDialog.start')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
function ProjectListSidebar({
projects,
selectedId,
onSelect,
onNew,
onAIScript,
onNSFWScript,
onLLM,
loading,
collapsed,
onToggle,
isSuperuser,
hasNsfwAccess,
}: {
projects: AudiobookProject[]
selectedId: number | null
onSelect: (id: number) => void
onNew: () => void
onAIScript: () => void
onNSFWScript: () => void
onLLM: () => void
loading: boolean
collapsed: boolean
onToggle: () => void
isSuperuser?: boolean
hasNsfwAccess?: boolean
}) {
const { t } = useTranslation('audiobook')
return (
<div className={`${collapsed ? 'w-10' : 'w-64'} shrink-0 flex flex-col bg-muted/30 overflow-hidden transition-all duration-200`}>
<div className="h-16 flex items-center shrink-0 px-2 gap-1">
{!collapsed && (
<div className="flex items-center gap-2 flex-1 min-w-0 ml-1">
<img src="/qwen.svg" alt="Qwen" className="h-5 w-5 shrink-0" />
<span className="text-sm font-semibold truncate">{t('title')}</span>
</div>
)}
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={onToggle}>
{collapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{!collapsed && (
<>
<div className="flex items-center justify-end px-2 pb-1 gap-0.5 shrink-0">
{isSuperuser && (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onLLM} title={t('llmConfig')}>
<Settings2 className="h-4 w-4" />
</Button>
)}
{hasNsfwAccess && (
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
<NsfwIcon 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>
</div>
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1.5">
{loading ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('loading')}</div>
) : projects.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground">{t('noProjects')}</div>
) : (
projects.map(p => {
const isNsfw = p.source_type === 'ai_generated' && !!p.script_config?.nsfw_mode
const ProjectIcon = p.source_type === 'epub'
? BookOpen
: isNsfw
? NsfwIcon
: p.source_type === 'ai_generated'
? Zap
: Book
const iconClass = isNsfw
? 'h-3.5 w-3.5 shrink-0 text-red-500'
: 'h-3.5 w-3.5 shrink-0 text-muted-foreground'
return (
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
selectedId === p.id
? 'bg-muted/60 border-border shadow-sm'
: 'bg-muted/20 border-border/30 hover:bg-muted/40'
}`}
>
<div className="flex items-center gap-2">
{(() => { const sc = STATUS_CONFIG[p.status]; return sc ? <sc.Icon className={`${sc.iconCls} shrink-0`} /> : null })()}
<ProjectIcon className={iconClass} />
<span className="text-sm font-medium truncate flex-1" title={p.title}>{p.title}</span>
</div>
</button>
)
})
)}
</div>
</>
)}
</div>
)
}
function EmptyState() {
const { t } = useTranslation('audiobook')
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Book className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>{t('noProjects')}</p>
<p className="text-sm mt-1">{t('noProjectsHint')}</p>
</div>
</div>
)
}
function CharactersPanel({
project,
detail,
loadingAction,
onConfirm,
onAnalyze,
onFetchDetail,
collapsed,
onToggle,
onScrollToChapter,
}: {
project: AudiobookProject
detail: AudiobookProjectDetail | null
loadingAction: boolean
onConfirm: () => void
onAnalyze: () => void
onFetchDetail: () => Promise<void>
collapsed: boolean
onToggle: () => void
onScrollToChapter: (chapterId: number) => void
}) {
const { t } = useTranslation('audiobook')
const [editingCharId, setEditingCharId] = useState<number | null>(null)
const [editFields, setEditFields] = useState({ name: '', gender: '', description: '', instruct: '' })
const [regeneratingVoices, setRegeneratingVoices] = useState<Set<number>>(new Set())
const [voiceKeys, setVoiceKeys] = useState<Record<number, number>>({})
const status = project.status
const isActive = ['analyzing', 'generating'].includes(status)
useEffect(() => {
if (status !== 'characters_ready') setEditingCharId(null)
}, [status])
const startEditChar = (char: AudiobookCharacter) => {
setEditingCharId(char.id)
setEditFields({ name: char.name, gender: char.gender || '', description: char.description || '', instruct: char.instruct || '' })
}
const saveEditChar = async (char: AudiobookCharacter) => {
try {
const updated = await audiobookApi.updateCharacter(project.id, char.id, {
name: editFields.name || char.name,
gender: editFields.gender || undefined,
description: editFields.description,
instruct: editFields.instruct,
})
setEditingCharId(null)
await onFetchDetail()
if (updated.voice_design_id) {
handleRegeneratePreview(char.id)
} else {
toast.success(t('projectCard.characters.savedSuccess'))
}
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleRegeneratePreview = async (charId: number) => {
setRegeneratingVoices(prev => new Set(prev).add(charId))
try {
await audiobookApi.regenerateCharacterPreview(project.id, charId)
toast.success(t('projectCard.characters.savedSuccess'))
setVoiceKeys(prev => ({ ...prev, [charId]: (prev[charId] || 0) + 1 }))
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setRegeneratingVoices(prev => { const n = new Set(prev); n.delete(charId); return n })
}
}
const handleRegenerateAllPreviews = async () => {
const charsWithVoice = detail?.characters.filter(c => c.voice_design_id) ?? []
if (charsWithVoice.length === 0) return
const ids = charsWithVoice.map(c => c.id)
setRegeneratingVoices(new Set(ids))
for (const id of ids) {
try {
await audiobookApi.regenerateCharacterPreview(project.id, id)
setVoiceKeys(prev => ({ ...prev, [id]: (prev[id] || 0) + 1 }))
} catch (e: any) {
toast.error(`${detail?.characters.find(c => c.id === id)?.name}: ${formatApiError(e)}`)
} finally {
setRegeneratingVoices(prev => { const n = new Set(prev); n.delete(id); return n })
}
}
toast.success(t('projectCard.characters.regenerateAllDone'))
}
const charCount = detail?.characters.length ?? 0
const hasChaptersOutline = (detail?.chapters.length ?? 0) > 0
if (collapsed && hasChaptersOutline) {
return (
<div className="w-44 shrink-0 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-border/30">
<span className="text-xs font-medium text-muted-foreground truncate">
{t('projectCard.chapters.title', { count: detail!.chapters.length })}
</span>
<Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}>
<PanelLeftOpen className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
{detail!.chapters.map(ch => {
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
const dotClass = ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-muted-foreground/80' : ch.status === 'done' ? 'bg-muted-foreground/30' : ch.status === 'ready' ? 'bg-muted-foreground/50' : 'bg-muted-foreground/25'
return (
<button
key={ch.id}
className="w-full flex items-center gap-1.5 px-3 py-2 text-left rounded-lg border bg-muted/30 shadow-sm hover:bg-muted/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={() => onScrollToChapter(ch.id)}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotClass}`} />
<span className="text-xs truncate">{chTitle}</span>
</button>
)
})}
</div>
</div>
)
}
return (
<div className="w-72 shrink-0 flex flex-col rounded-xl border border-blue-500/20 bg-blue-500/5 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 shrink-0">
<span className="text-xs font-medium text-blue-400/80">
{t('projectCard.characters.title', { count: charCount })}
</span>
<div className="flex items-center gap-1">
{!isActive && status !== 'pending' && charCount > 0 && (
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
{t('projectCard.reanalyze')}
</Button>
)}
{!isActive && status === 'characters_ready' && (detail?.characters.some(c => c.voice_design_id) ?? false) && (
<Button
size="xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleRegenerateAllPreviews}
disabled={regeneratingVoices.size > 0}
>
{regeneratingVoices.size > 0
? <><Loader2 className="h-3 w-3 mr-1 animate-spin" /></>
: <><RefreshCw className="h-3 w-3 mr-1" /></>
}
</Button>
)}
{hasChaptersOutline && (
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onToggle}>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<Dialog open={editingCharId !== null} onOpenChange={open => { if (!open) setEditingCharId(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('projectCard.characters.editTitle', { name: detail?.characters.find(c => c.id === editingCharId)?.name ?? '' })}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<label className="text-sm font-medium">{t('projectCard.characters.namePlaceholder')}</label>
<Input
value={editFields.name}
onChange={e => setEditFields(f => ({ ...f, name: e.target.value }))}
placeholder={t('projectCard.characters.namePlaceholder')}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('projectCard.characters.genderPlaceholder')}</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
value={editFields.gender}
onChange={e => setEditFields(f => ({ ...f, gender: e.target.value }))}
>
<option value="">{t('projectCard.characters.genderPlaceholder')}</option>
<option value="男">{t('projectCard.characters.genderMale')}</option>
<option value="女">{t('projectCard.characters.genderFemale')}</option>
<option value="未知">{t('projectCard.characters.genderUnknown')}</option>
</select>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('projectCard.characters.instructPlaceholder')}</label>
<Textarea
value={editFields.instruct}
onChange={e => setEditFields(f => ({ ...f, instruct: e.target.value }))}
placeholder={t('projectCard.characters.instructPlaceholder')}
rows={4}
className="resize-none"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">{t('projectCard.characters.descPlaceholder')}</label>
<Input
value={editFields.description}
onChange={e => setEditFields(f => ({ ...f, description: e.target.value }))}
placeholder={t('projectCard.characters.descPlaceholder')}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setEditingCharId(null)}>{t('common:cancel')}</Button>
{editingCharId !== null && detail && (() => {
const editingChar = detail.characters.find(c => c.id === editingCharId)
return editingChar ? (
<Button onClick={() => saveEditChar(editingChar)}>
<Check className="h-3 w-3 mr-1" />{t('common:save')}
</Button>
) : null
})()}
</DialogFooter>
</DialogContent>
</Dialog>
{(!detail || charCount === 0) ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-3 text-center">
{status === 'pending' ? t('stepHints.pending') : t('projectCard.characters.title', { count: 0 })}
</div>
) : (
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
{detail.characters.map(char => (
<div key={char.id} className="border rounded-lg px-3 py-2 bg-muted/30 shadow-sm">
<div className="flex flex-col gap-1 text-sm">
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`font-medium truncate ${char.gender === '男' ? 'text-blue-400' : char.gender === '女' ? 'text-pink-400' : ''}`}>{char.name}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{status === 'characters_ready' && (
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEditChar(char)}>
<Pencil className="h-3 w-3" />
</Button>
)}
</div>
</div>
{char.description && <span className="text-xs text-muted-foreground">{char.description}</span>}
{char.instruct && <span className="text-xs text-muted-foreground/70">{char.instruct}</span>}
{!char.voice_design_id && (
<div className="text-xs text-muted-foreground/60">{t('projectCard.characters.noVoice')}</div>
)}
</div>
{!editingCharId && char.voice_design_id && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 min-w-0">
{regeneratingVoices.has(char.id)
? <div className="flex items-center gap-1.5 text-xs text-muted-foreground py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.characters.regeneratingPreview')}</div>
: <LazyAudioPlayer
key={`audio-${char.id}-${voiceKeys[char.id] || 0}`}
audioUrl={`${audiobookApi.getCharacterAudioUrl(project.id, char.id)}?t=${voiceKeys[char.id] || 0}`}
jobId={char.id}
compact
/>
}
</div>
{status === 'characters_ready' && !regeneratingVoices.has(char.id) && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground shrink-0"
onClick={() => handleRegeneratePreview(char.id)}
>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
))}
</div>
)}
{status === 'characters_ready' && (
<div className="px-2 py-2 border-t shrink-0">
<Button
className="w-full"
onClick={onConfirm}
disabled={loadingAction}
>
{loadingAction
? t('projectCard.confirm.loading')
: project.source_type === 'ai_generated'
? t('projectCard.confirm.generateScript', '确认角色并生成剧本')
: t('projectCard.confirm.button')}
</Button>
</div>
)}
</div>
)
}
const EMOTION_OPTIONS = ['开心', '愤怒', '悲伤', '恐惧', '厌恶', '低沉', '惊讶']
const EMO_LEVEL_MAX: Record<string, number> = {
'开心': 0.75, '愤怒': 0.08, '悲伤': 0.90,
'恐惧': 0.10, '厌恶': 0.50, '低沉': 0.35, '惊讶': 0.35,
}
function ChaptersPanel({
project,
detail,
segments,
loadingAction,
generatingChapterIndices,
sequentialPlayingId,
onParseChapter,
onGenerate,
onParseAll,
onGenerateAll,
onProcessAll,
isBackgroundGenerating,
onContinueScript,
onDownload,
onSequentialPlayingChange,
onUpdateSegment,
onRegenerateSegment,
scrollToChapterId,
onScrollToChapterDone,
}: {
project: AudiobookProject
detail: AudiobookProjectDetail | null
segments: AudiobookSegment[]
loadingAction: boolean
generatingChapterIndices: Set<number>
sequentialPlayingId: number | null
onParseChapter: (chapterId: number, title?: string) => void
onGenerate: (chapterIndex?: number, force?: boolean) => void
onParseAll: () => void
onGenerateAll: () => void
onProcessAll: () => void
isBackgroundGenerating: boolean
onContinueScript?: () => void
onDownload: (chapterIndex?: number) => void
onSequentialPlayingChange: (id: number | null) => void
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
onRegenerateSegment: (segmentId: number) => Promise<void>
scrollToChapterId: number | null
onScrollToChapterDone: () => void
}) {
const { t } = useTranslation('audiobook')
const [expandedChapters, setExpandedChapters] = useState<Set<number>>(new Set())
const [editingSegId, setEditingSegId] = useState<number | null>(null)
const [editText, setEditText] = useState('')
const [editEmoSelections, setEditEmoSelections] = useState<string[]>([])
const [editEmoWeights, setEditEmoWeights] = useState<Record<string, number>>({})
const [savingSegId, setSavingSegId] = useState<number | null>(null)
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
const [chapterPlayerChIdx, setChapterPlayerChIdx] = useState<number | null>(null)
const [confirmAction, setConfirmAction] = useState<{ label: string; onConfirm: () => void } | null>(null)
const prevSegStatusRef = useRef<Record<number, string>>({})
const initialExpandDoneRef = useRef(false)
const segRefs = useRef<Record<number, HTMLDivElement | null>>({})
const scrollContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!scrollToChapterId) return
const el = document.getElementById(`ch-${scrollToChapterId}`)
const container = scrollContainerRef.current
if (el && container) {
const top = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop
container.scrollTo({ top, behavior: 'smooth' })
}
onScrollToChapterDone()
}, [scrollToChapterId, onScrollToChapterDone])
useEffect(() => {
if (sequentialPlayingId === null || !detail) return
const seg = segments.find(s => s.id === sequentialPlayingId)
if (!seg) return
const ch = detail.chapters.find(c => c.chapter_index === seg.chapter_index)
if (ch) setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
setTimeout(() => {
segRefs.current[sequentialPlayingId]?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 50)
}, [sequentialPlayingId, detail, segments])
useEffect(() => {
const bumps: Record<number, number> = {}
segments.forEach(seg => {
if (prevSegStatusRef.current[seg.id] === 'generating' && seg.status === 'done') {
bumps[seg.id] = (audioVersions[seg.id] ?? 0) + 1
}
})
if (Object.keys(bumps).length > 0) {
setAudioVersions(prev => ({ ...prev, ...bumps }))
}
const next: Record<number, string> = {}
segments.forEach(seg => { next[seg.id] = seg.status })
prevSegStatusRef.current = next
}, [segments])
const startEdit = (seg: AudiobookSegment) => {
setEditingSegId(seg.id)
setEditText(seg.text)
const rawEmo = seg.emo_text || ''
const alpha = seg.emo_alpha ?? 5
if (!rawEmo) {
setEditEmoSelections([])
setEditEmoWeights({})
return
}
const tokens = rawEmo.split('+').filter(Boolean)
const selections: string[] = []
const weights: Record<string, number> = {}
if (tokens.length === 1) {
const [name] = tokens[0].split(':')
const emoName = name.trim()
selections.push(emoName)
// Convert old float alpha to level if needed
weights[emoName] = alpha > 1 ? Math.round(alpha) : Math.round(alpha / (EMO_LEVEL_MAX[emoName] || 0.35) * 10)
} else {
for (const tok of tokens) {
const [name, w] = tok.split(':')
const emo = name.trim()
selections.push(emo)
const rawW = w ? parseFloat(w) : (EMO_LEVEL_MAX[emo] || 0.35) * 0.5
weights[emo] = Math.round(rawW / (EMO_LEVEL_MAX[emo] || 0.35) * 10)
}
}
setEditEmoSelections(selections)
setEditEmoWeights(weights)
}
const cancelEdit = () => setEditingSegId(null)
const saveEdit = async (segId: number) => {
setSavingSegId(segId)
try {
let emo_text: string | null = null
let emo_alpha: number | null = null
if (editEmoSelections.length === 1) {
emo_text = editEmoSelections[0]
emo_alpha = editEmoWeights[editEmoSelections[0]] ?? 5
} else if (editEmoSelections.length > 1) {
emo_text = editEmoSelections.map(e => {
const level = editEmoWeights[e] ?? 5
const weight = parseFloat((level / 10 * (EMO_LEVEL_MAX[e] || 0.35)).toFixed(4))
return `${e}:${weight}`
}).join('+')
emo_alpha = 1.0
}
await onUpdateSegment(segId, { text: editText, emo_text, emo_alpha })
setEditingSegId(null)
} finally {
setSavingSegId(null)
}
}
const handleRegenerate = async (segId: number) => {
setRegeneratingSegs(prev => new Set([...prev, segId]))
try {
await onRegenerateSegment(segId)
} finally {
setRegeneratingSegs(prev => { const n = new Set(prev); n.delete(segId); return n })
}
}
const status = project.status
const doneCount = segments.filter(s => s.status === 'done').length
useEffect(() => {
if (!detail || segments.length === 0) return
const generatingChapterIds = detail.chapters
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating'))
.map(ch => ch.id)
if (generatingChapterIds.length > 0) {
setExpandedChapters(prev => {
const next = new Set(prev)
generatingChapterIds.forEach(id => next.add(id))
return next.size === prev.size ? prev : next
})
return
}
if (!initialExpandDoneRef.current) {
initialExpandDoneRef.current = true
const chapterIdsWithSegs = detail.chapters
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index))
.map(ch => ch.id)
if (chapterIdsWithSegs.length > 0) {
setExpandedChapters(new Set(chapterIdsWithSegs))
}
}
}, [segments, detail])
const isAIMode = project.source_type === 'ai_generated'
const hasChapters = detail && detail.chapters.length > 0 && ['analyzing', 'ready', 'generating', 'done'].includes(status)
return (
<>
<Dialog open={!!confirmAction} onOpenChange={open => { if (!open) setConfirmAction(null) }}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{confirmAction?.label}</p>
<div className="flex justify-end gap-2 mt-2">
<Button variant="ghost" size="sm" onClick={() => setConfirmAction(null)}></Button>
<Button variant="destructive" size="sm" onClick={() => { confirmAction?.onConfirm(); setConfirmAction(null) }}></Button>
</div>
</DialogContent>
</Dialog>
<div className="flex-1 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 shrink-0">
<span className="text-xs font-medium text-muted-foreground">
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
</span>
{hasChapters && (
<div className="flex items-center gap-1 flex-wrap">
{doneCount > 0 && <SequentialPlayer segments={segments} projectId={project.id} onPlayingChange={onSequentialPlayingChange} />}
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && !isBackgroundGenerating && (
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onParseAll}>
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
</Button>
)}
{detail!.chapters.some(c => c.status === 'ready') && (
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onGenerateAll}>
<Volume2 className="h-3 w-3 mr-1" />
</Button>
)}
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && (
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onProcessAll}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
</Button>
)}
{isAIMode && onContinueScript && (
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={onContinueScript}>
<Plus className="h-3 w-3 mr-1" />
</Button>
)}
</div>
)}
</div>
{!hasChapters ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
) : (
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2" ref={scrollContainerRef}>
{detail!.chapters.map(ch => {
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
const chDone = chSegs.filter(s => s.status === 'done').length
const chTotal = chSegs.length
const chGenerating = chSegs.some(s => s.status === 'generating')
const chAllDone = chTotal > 0 && chDone === chTotal
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
const chExpanded = expandedChapters.has(ch.id)
const toggleChExpand = () => setExpandedChapters(prev => {
const next = new Set(prev)
if (next.has(ch.id)) next.delete(ch.id)
else next.add(ch.id)
return next
})
const chCardBorder = ch.status === 'error' ? 'border-destructive/40' : ch.status === 'parsing' || ch.status === 'generating' || chGenerating ? 'border-muted-foreground/50' : ch.status === 'done' || (ch.status === 'ready' && chAllDone) ? 'border-border/30' : ch.status === 'ready' ? 'border-muted-foreground/30' : 'border-border/25'
return (
<div key={ch.id} id={`ch-${ch.id}`} className={`rounded-xl border overflow-clip ${chCardBorder}`}>
<button
className="sticky top-0 z-10 w-full flex items-center gap-2 px-3 py-2.5 bg-background/90 backdrop-blur-sm hover:bg-muted/60 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={toggleChExpand}
>
<span className="shrink-0 text-muted-foreground">
{chExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</span>
<span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
{ch.status === 'pending' && (
isBackgroundGenerating ? (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
) : (
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={() => onParseChapter(ch.id, ch.title)}>
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
{t(isAIMode ? 'projectCard.chapters.parseAI' : 'projectCard.chapters.parse')}
</Button>
)
)}
{ch.status === 'parsing' && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.parsing')}
</span>
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
<>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index)
}}>
<Volume2 className="h-3 w-3 mr-1" />
{t('projectCard.chapters.generate')}
</Button>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => setConfirmAction({ label: '重新解析将删除已有台词,确认继续?', onConfirm: () => onParseChapter(ch.id, ch.title) })}>
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
</Button>
</>
)}
{ch.status === 'ready' && (chGenerating || generatingChapterIndices.has(ch.chapter_index)) && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
</span>
)}
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
<>
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => setConfirmAction({ label: '重新生成将覆盖已有音频,确认继续?', onConfirm: () => { setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n }); onGenerate(ch.chapter_index, true) } })}>
<RefreshCw className="h-3 w-3 mr-0.5" /><Volume2 className="h-3 w-3 mr-1" />{t('projectCard.chapters.generate')}
</Button>
<Button
size="icon"
variant={chapterPlayerChIdx === ch.chapter_index ? 'secondary' : 'ghost'}
className="h-6 w-6"
onClick={() => setChapterPlayerChIdx(prev => prev === ch.chapter_index ? null : ch.chapter_index)}
title="播放本章"
>
<Headphones className="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
<Download className="h-3 w-3" />
</Button>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => setConfirmAction({ label: '重新解析将删除已有台词和音频,确认继续?', onConfirm: () => onParseChapter(ch.id, ch.title) })}>
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
</Button>
</>
)}
{ch.status === 'error' && (
<Button size="xs" variant="ghost" className="text-destructive" onClick={() => onParseChapter(ch.id, ch.title)}>
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
{t(isAIMode ? 'projectCard.chapters.reparseAI' : 'projectCard.chapters.reparse')}
</Button>
)}
</span>
</button>
{ch.status === 'parsing' && (
<div className="px-3 py-1 border-t">
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
</div>
)}
{chExpanded && chSegs.length > 0 && (
<div className="px-3 py-2 space-y-2 border-t">
{chSegs.map(seg => {
const isEditing = editingSegId === seg.id
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
const isSaving = savingSegId === seg.id
const audioV = audioVersions[seg.id] ?? 0
return (
<div
key={seg.id}
ref={el => { segRefs.current[seg.id] = el }}
className={`rounded-lg border overflow-hidden ${sequentialPlayingId === seg.id ? 'border-red-500 ring-1 ring-red-500' : 'bg-card'}`}
>
{/* Card header */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground shrink-0">
<span className="text-sm text-foreground">{seg.character_name || t('projectCard.segments.unknownCharacter')}</span>
{!isEditing && seg.emo_text && (
<>
{' | '}
{seg.emo_text.split('+').map((tok, i) => {
const [name, w] = tok.split(':')
return <span key={tok}>{i > 0 ? ' ' : ''}{name.trim()}{w ? `:${parseFloat(w).toFixed(2)}` : ''}</span>
})}
{seg.emo_alpha != null && seg.emo_alpha !== 1 && ` Lv.${seg.emo_alpha > 1 ? Math.round(seg.emo_alpha) : seg.emo_alpha}`}
</>
)}
</span>
{isRegenerating && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
{!isRegenerating && seg.status === 'error' && (
<Badge variant="destructive" className="text-xs">{t('projectCard.segments.errorBadge')}</Badge>
)}
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{!isEditing ? (
<>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
<RefreshCw className="h-3 w-3" />
</Button>
</>
) : (
<>
<Button size="icon" variant="ghost" className="h-6 w-6 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
<X className="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" className="h-6 w-6 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</Button>
</>
)}
</div>
</div>
{/* Card body */}
<div className="px-3 py-2.5">
{isEditing ? (
<div className="space-y-2">
<Textarea
value={editText}
onChange={e => setEditText(e.target.value)}
className="text-sm min-h-[60px] resize-y"
rows={3}
/>
<div className="space-y-1.5">
<div className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-muted-foreground shrink-0">{t('projectCard.segments.emotion')}:</span>
{EMOTION_OPTIONS.map(emo => {
const isSelected = editEmoSelections.includes(emo)
return (
<button
key={emo}
type="button"
className={`px-2 py-0.5 rounded text-xs border transition-colors ${isSelected ? "bg-primary text-primary-foreground border-primary" : "bg-muted text-muted-foreground border-transparent"}`}
onClick={() => {
if (isSelected) {
setEditEmoSelections(prev => prev.filter(e => e !== emo))
} else {
setEditEmoSelections(prev => [...prev, emo])
setEditEmoWeights(prev => ({ ...prev, [emo]: prev[emo] ?? 5 }))
}
}}
>
{emo}
</button>
)
})}
</div>
{editEmoSelections.map(emo => (
<div key={emo} className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground w-8 shrink-0">{emo}:</span>
<input
type="range"
min={1}
max={10}
step={1}
value={editEmoWeights[emo] ?? 5}
onChange={e => setEditEmoWeights(prev => ({ ...prev, [emo]: Number(e.target.value) }))}
className="flex-1 h-1.5 accent-primary"
/>
<span className="text-xs text-muted-foreground w-6 text-right">{editEmoWeights[emo] ?? 5}</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-foreground/80 break-words leading-relaxed">{seg.text}</p>
)}
</div>
{/* Audio — integrated at bottom of card */}
{!isEditing && seg.status === 'done' && (
<div className="px-3 pb-2">
<LazyAudioPlayer
key={`seg-audio-${seg.id}-${audioV}`}
audioUrl={`${audiobookApi.getSegmentAudioUrl(project.id, seg.id)}?v=${audioV}`}
jobId={seg.id}
/>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
{chapterPlayerChIdx !== null && (() => {
const activeCh = detail?.chapters.find(c => c.chapter_index === chapterPlayerChIdx)
const activeSegs = segments.filter(s => s.chapter_index === chapterPlayerChIdx)
if (!activeCh) return null
return (
<div className="shrink-0">
<ChapterPlayer
projectId={project.id}
chapterIndex={chapterPlayerChIdx}
chapterTitle={activeCh.title || t('projectCard.chapters.defaultTitle', { index: chapterPlayerChIdx + 1 })}
segments={activeSegs}
onClose={() => setChapterPlayerChIdx(null)}
/>
</div>
)
})()}
</div>
</>
)
}
export default function Audiobook() {
const { t } = useTranslation('audiobook')
const { user } = useAuth()
const [projects, setProjects] = useState<AudiobookProject[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
const [segments, setSegments] = useState<AudiobookSegment[]>([])
const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [loadingAction, setLoadingAction] = useState(false)
const [isPolling, setIsPolling] = useState(false)
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 [showNSFWScript, setShowNSFWScript] = useState(false)
const [showContinueScript, setShowContinueScript] = useState(false)
const [showLLM, setShowLLM] = useState(false)
const [hasNsfwAccess, setHasNsfwAccess] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
const [scrollToChapterId, setScrollToChapterId] = useState<number | null>(null)
const prevStatusRef = useRef<string>('')
const autoExpandedRef = useRef(new Set<string>())
const selectedProject = projects.find(p => p.id === selectedProjectId) ?? null
const fetchProjects = useCallback(async () => {
try {
const list = await audiobookApi.listProjects()
setProjects(list)
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoading(false)
}
}, [])
const fetchDetail = useCallback(async () => {
if (!selectedProjectId) return
try { setDetail(await audiobookApi.getProject(selectedProjectId)) } catch {}
}, [selectedProjectId])
const fetchSegments = useCallback(async () => {
if (!selectedProjectId) return
try { setSegments(await audiobookApi.getSegments(selectedProjectId)) } catch {}
}, [selectedProjectId])
useEffect(() => {
fetchProjects()
authApi.getNsfwAccess().then(r => setHasNsfwAccess(r.has_access)).catch(() => {})
}, [fetchProjects])
useEffect(() => {
if (!selectedProjectId) {
setDetail(null)
setSegments([])
return
}
setDetail(null)
setSegments([])
setGeneratingChapterIndices(new Set())
setIsPolling(false)
autoExpandedRef.current.clear()
prevStatusRef.current = ''
setDetailLoading(true)
Promise.all([fetchDetail(), fetchSegments()]).finally(() => setDetailLoading(false))
}, [selectedProjectId, fetchDetail, fetchSegments])
const status = selectedProject?.status ?? ''
useEffect(() => {
if (!selectedProject) return
if (['done', 'error'].includes(status)) setIsPolling(false)
if (['characters_ready', 'ready', 'generating'].includes(status) && !autoExpandedRef.current.has(status)) {
autoExpandedRef.current.add(status)
fetchDetail()
fetchSegments()
}
}, [status, selectedProject, fetchDetail, fetchSegments])
useEffect(() => {
if (!selectedProject) return
if (prevStatusRef.current === 'generating' && status === 'done') {
toast.success(t('projectCard.allDoneToast', { title: selectedProject.title }))
}
prevStatusRef.current = status
}, [status, selectedProject, t])
const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false
const isAiProject = selectedProject?.source_type === 'ai_generated'
const hasPendingChapters = detail?.chapters.some(c => c.status === 'pending') ?? false
const isBackgroundGenerating = isAiProject && (status === 'analyzing' || (status === 'ready' && hasPendingChapters))
useEffect(() => {
if (!isPolling) return
if (['analyzing', 'generating'].includes(status)) return
if (hasParsingChapter) return
if (isAiProject && hasPendingChapters) return
if (!segments.some(s => s.status === 'generating')) setIsPolling(false)
}, [isPolling, status, segments, hasParsingChapter, isAiProject, hasPendingChapters])
useEffect(() => {
if (generatingChapterIndices.size === 0) return
const done: number[] = []
generatingChapterIndices.forEach(chIdx => {
const chSegs = segments.filter(s => s.chapter_index === chIdx)
if (chSegs.length > 0 && chSegs.every(s => s.status === 'done' || s.status === 'error')) {
done.push(chIdx)
}
})
if (done.length > 0) {
setGeneratingChapterIndices(prev => {
const n = new Set(prev)
done.forEach(i => n.delete(i))
return n
})
}
}, [segments, generatingChapterIndices])
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || (isAiProject && hasPendingChapters) || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating')
useEffect(() => {
if (!shouldPoll || !selectedProjectId) return
const id = setInterval(() => { fetchProjects(); fetchSegments(); fetchDetail() }, 1500)
return () => clearInterval(id)
}, [shouldPoll, selectedProjectId, fetchProjects, fetchSegments, fetchDetail])
const handleAnalyze = async () => {
if (!selectedProject) return
if (['characters_ready', 'ready', 'done'].includes(status)) {
if (!confirm(t('projectCard.reanalyzeConfirm'))) return
}
autoExpandedRef.current.clear()
setLoadingAction(true)
setIsPolling(true)
try {
if (selectedProject.source_type === 'ai_generated') {
await audiobookApi.regenerateCharacters(selectedProject.id)
} else {
await audiobookApi.analyze(selectedProject.id, { turbo: true })
}
toast.success(t('projectCard.analyzeStarted'))
fetchProjects()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleConfirm = async () => {
if (!selectedProject) return
setLoadingAction(true)
try {
await audiobookApi.confirmCharacters(selectedProject.id)
if (selectedProject.source_type === 'ai_generated') setIsPolling(true)
toast.success(t('projectCard.confirm.chaptersRecognized'))
fetchProjects()
fetchDetail()
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleParseChapter = async (chapterId: number, title?: string) => {
if (!selectedProject) return
try {
await audiobookApi.parseChapter(selectedProject.id, chapterId)
toast.success(title
? t('projectCard.chapters.parseStarted', { title })
: t('projectCard.chapters.parseStartedDefault'))
fetchDetail()
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleGenerate = async (chapterIndex?: number, force?: boolean) => {
if (!selectedProject) return
setLoadingAction(true)
if (chapterIndex !== undefined) {
setGeneratingChapterIndices(prev => new Set([...prev, chapterIndex]))
} else {
setIsPolling(true)
}
try {
await audiobookApi.generate(selectedProject.id, chapterIndex, force)
toast.success(chapterIndex !== undefined
? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 })
: t('projectCard.chapters.generateAllStarted'))
fetchProjects()
fetchSegments()
} catch (e: any) {
if (chapterIndex !== undefined) {
setGeneratingChapterIndices(prev => { const n = new Set(prev); n.delete(chapterIndex); return n })
} else {
setIsPolling(false)
}
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleParseAll = async () => {
if (!selectedProject) return
setLoadingAction(true)
setIsPolling(true)
try {
const isAI = selectedProject.source_type === 'ai_generated'
await audiobookApi.parseAllChapters(selectedProject.id, false, isAI)
toast.success(t('projectCard.chapters.parseAllStarted'))
fetchProjects()
fetchDetail()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleRetryFailed = async () => {
if (!selectedProject) return
setIsPolling(true)
try {
await audiobookApi.parseAllChapters(selectedProject.id, true)
toast.success(t('projectCard.chapters.parseAllStarted'))
fetchProjects()
fetchDetail()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
}
}
const handleGenerateAll = async () => {
if (!selectedProject || !detail) return
setLoadingAction(true)
const ready = detail.chapters.filter(c => c.status === 'ready')
if (ready.length > 0) {
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
}
setIsPolling(true)
try {
await audiobookApi.generate(selectedProject.id)
toast.success(t('projectCard.chapters.generateAllStarted'))
fetchProjects()
fetchSegments()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleProcessAll = async () => {
if (!selectedProject || !detail) return
setLoadingAction(true)
const ready = detail.chapters.filter(c => c.status === 'ready')
if (ready.length > 0) {
setGeneratingChapterIndices(prev => new Set([...prev, ...ready.map(c => c.chapter_index)]))
}
setIsPolling(true)
try {
await audiobookApi.processAll(selectedProject.id)
toast.success(t('projectCard.chapters.processAllStarted'))
fetchProjects()
fetchDetail()
fetchSegments()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleContinueScript = async (additionalChapters: number) => {
if (!selectedProject) return
setLoadingAction(true)
setIsPolling(true)
try {
await audiobookApi.continueScript(selectedProject.id, additionalChapters)
toast.success(t('projectCard.chapters.continueScriptStarted'))
setShowContinueScript(false)
fetchProjects()
fetchDetail()
} catch (e: any) {
setIsPolling(false)
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleCancelBatch = async () => {
if (!selectedProject) return
try {
await audiobookApi.cancelBatch(selectedProject.id)
toast.success(t('projectCard.cancelledToast'))
setIsPolling(false)
setGeneratingChapterIndices(new Set())
setTimeout(() => { fetchProjects(); fetchDetail(); fetchSegments() }, 1000)
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const handleUpdateSegment = async (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => {
if (!selectedProject) return
try {
await audiobookApi.updateSegment(selectedProject.id, segmentId, data)
toast.success(t('projectCard.segments.savedSuccess'))
fetchSegments()
} catch (e: any) {
toast.error(formatApiError(e))
throw e
}
}
const handleRegenerateSegment = async (segmentId: number) => {
if (!selectedProject) return
try {
await audiobookApi.regenerateSegment(selectedProject.id, segmentId)
fetchSegments()
} catch (e: any) {
toast.error(formatApiError(e))
throw e
}
}
const handleDownload = async (chapterIndex?: number) => {
if (!selectedProject) return
setLoadingAction(true)
try {
const response = await apiClient.get(`/audiobook/projects/${selectedProject.id}/download`, {
responseType: 'blob',
params: chapterIndex !== undefined ? { chapter: chapterIndex } : {},
})
const url = URL.createObjectURL(response.data)
const a = document.createElement('a')
a.href = url
a.download = chapterIndex !== undefined
? `${selectedProject.title}_ch${chapterIndex + 1}.mp3`
: `${selectedProject.title}.mp3`
a.click()
URL.revokeObjectURL(url)
} catch (e: any) {
toast.error(formatApiError(e))
} finally {
setLoadingAction(false)
}
}
const handleDelete = async () => {
if (!selectedProject) return
if (!confirm(t('projectCard.deleteConfirm', { title: selectedProject.title }))) return
try {
await audiobookApi.deleteProject(selectedProject.id)
toast.success(t('projectCard.deleteSuccess'))
setSelectedProjectId(null)
setDetail(null)
setSegments([])
fetchProjects()
} catch (e: any) {
toast.error(formatApiError(e))
}
}
const doneCount = segments.filter(s => s.status === 'done').length
const totalCount = segments.length
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
const chaptersParsed = detail?.chapters.filter(c => ['ready', 'done'].includes(c.status) || (segments.some(s => s.chapter_index === c.chapter_index))).length ?? 0
const chaptersParsing = detail?.chapters.filter(c => c.status === 'parsing').length ?? 0
const chaptersError = detail?.chapters.filter(c => c.status === 'error').length ?? 0
const chaptersTotal = detail?.chapters.length ?? 0
const chapterProgress = chaptersTotal > 0 ? Math.round((chaptersParsed / chaptersTotal) * 100) : 0
const hasGenerating = segments.some(s => s.status === 'generating')
const displayStatus = (() => {
if (!selectedProject) return ''
const s = selectedProject.status
if (['ready', 'generating', 'done'].includes(s)) {
if (chaptersParsing > 0 && hasGenerating) return 'processing'
if (chaptersParsing > 0) return 'parsing'
if (hasGenerating) return 'generating'
if (totalCount > 0 && doneCount === totalCount && chaptersTotal > 0 && chaptersParsing === 0 && chaptersError === 0) return 'done'
if (s === 'done' && (chaptersError > 0 || (chaptersTotal > 0 && chaptersParsed < chaptersTotal))) return 'ready'
}
return s
})()
const isTurboMode = ['analyzing', 'parsing', 'processing'].includes(displayStatus)
return (
<div className="h-screen overflow-hidden flex bg-background">
<ProjectListSidebar
projects={projects}
selectedId={selectedProjectId}
onSelect={(id) => {
if (id !== selectedProjectId) {
setSelectedProjectId(id)
setIsPolling(false)
setGeneratingChapterIndices(new Set())
}
}}
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false); setShowNSFWScript(false) }}
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false); setShowNSFWScript(false) }}
onNSFWScript={() => { setShowNSFWScript(v => !v); setShowCreate(false); setShowLLM(false); setShowAIScript(false) }}
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false); setShowNSFWScript(false) }}
loading={loading}
collapsed={!sidebarOpen}
onToggle={() => setSidebarOpen(v => !v)}
isSuperuser={user?.is_superuser}
hasNsfwAccess={hasNsfwAccess}
/>
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
<Navbar />
<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) }} />
<NSFWScriptDialog open={showNSFWScript} onClose={() => setShowNSFWScript(false)} onCreated={() => { fetchProjects(); setShowNSFWScript(false) }} />
<ContinueScriptDialog open={showContinueScript} onClose={() => setShowContinueScript(false)} onConfirm={handleContinueScript} />
{!selectedProject ? (
<EmptyState />
) : (
<>
<div className="shrink-0 border-b px-4 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
{(() => {
const isNsfw = selectedProject.source_type === 'ai_generated' && !!(selectedProject.script_config as any)?.nsfw_mode
const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? NsfwIcon : selectedProject.source_type === 'ai_generated' ? Zap : Book
const cls = isNsfw ? 'h-4 w-4 shrink-0 text-red-500' : 'h-4 w-4 shrink-0 text-muted-foreground'
return <Icon className={cls} />
})()}
<span className="font-medium break-words min-w-0">{selectedProject.title}</span>
{(() => {
const sc = STATUS_CONFIG[displayStatus]
return sc ? <sc.Icon className={`${sc.iconCls} shrink-0`} /> : null
})()}
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap justify-end">
{isTurboMode && (
<Badge variant="secondary" className="bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 shadow-sm border-none">
{t('status.turboActive')}
</Badge>
)}
{status === 'pending' && (
<Button size="sm" variant="ghost" onClick={handleAnalyze} disabled={loadingAction}>
{t('projectCard.analyze')}
</Button>
)}
{status === 'ready' && (
<Button size="sm" variant="ghost" onClick={handleProcessAll} disabled={loadingAction}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
{t('projectCard.processAll')}
</Button>
)}
{status === 'done' && (
<Button size="sm" variant="ghost" onClick={() => handleDownload()} disabled={loadingAction}>
<Download className="h-3 w-3" />{t('projectCard.downloadAll')}
</Button>
)}
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
<div className="shrink-0 mx-4 mt-2">
<LogStream projectId={selectedProject.id} active={true} />
</div>
)}
{selectedProject.error_message && (
<div className="shrink-0 mx-4 mt-2 text-xs text-destructive bg-destructive/10 rounded p-2">
{selectedProject.error_message}
</div>
)}
{chaptersTotal > 0 && (isBackgroundGenerating || ['ready', 'generating', 'done'].includes(status)) && (isBackgroundGenerating || chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && (
<div className="shrink-0 mx-4 mt-2 space-y-2">
{(isBackgroundGenerating || chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center justify-between">
<div className="flex items-center gap-1 flex-wrap">
{isBackgroundGenerating && chaptersParsing === 0 && !chaptersError
? <Loader2 className="h-3 w-3 animate-spin" />
: <span>📝</span>}
<span>{t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })}</span>
{chaptersParsing > 0 && (
<span className="text-primary">({t('projectCard.chaptersParsing', { count: chaptersParsing })})</span>
)}
{isBackgroundGenerating && hasPendingChapters && chaptersParsing > 0 && (
<span className="text-muted-foreground">({detail!.chapters.filter(c => c.status === 'pending').length} )</span>
)}
{chaptersError > 0 && (
<>
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={handleRetryFailed}>
{t('projectCard.retryFailed')}
</Button>
</>
)}
</div>
{(isBackgroundGenerating || chaptersParsing > 0) && (
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelParsing')}
</Button>
)}
</div>
<Progress value={chapterProgress} />
</div>
)}
{totalCount > 0 && doneCount > 0 && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center justify-between">
<div className="flex items-center gap-1">
<span>🎵</span>
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
</div>
{!chaptersParsing && hasGenerating && (
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelGenerating')}
</Button>
)}
</div>
<Progress value={progress} />
</div>
)}
{chaptersParsing > 0 && !totalCount && (
<div className="flex justify-end">
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelParsing')}
</Button>
</div>
)}
</div>
)}
{detailLoading ? (
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
<div className="w-56 shrink-0 flex flex-col gap-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 80}ms` }} />
))}
</div>
<div className="flex-1 flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-12 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 60}ms` }} />
))}
</div>
</div>
) : (
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
<CharactersPanel
project={selectedProject}
detail={detail}
loadingAction={loadingAction}
onConfirm={handleConfirm}
onAnalyze={handleAnalyze}
onFetchDetail={fetchDetail}
collapsed={charactersCollapsed}
onToggle={() => setCharactersCollapsed(v => !v)}
onScrollToChapter={(id) => setScrollToChapterId(id)}
/>
<ChaptersPanel
key={selectedProject.id}
project={selectedProject}
detail={detail}
segments={segments}
loadingAction={loadingAction}
generatingChapterIndices={generatingChapterIndices}
sequentialPlayingId={sequentialPlayingId}
onParseChapter={handleParseChapter}
onGenerate={handleGenerate}
onParseAll={handleParseAll}
onGenerateAll={handleGenerateAll}
onProcessAll={handleProcessAll}
isBackgroundGenerating={isBackgroundGenerating}
onContinueScript={selectedProject.source_type === 'ai_generated' ? () => setShowContinueScript(true) : undefined}
onDownload={handleDownload}
onSequentialPlayingChange={setSequentialPlayingId}
onUpdateSegment={handleUpdateSegment}
onRegenerateSegment={handleRegenerateSegment}
scrollToChapterId={scrollToChapterId}
onScrollToChapterDone={() => setScrollToChapterId(null)}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}