feat: add NSFW script generation feature and Grok API configuration
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot } from 'lucide-react'
|
||||
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -10,9 +10,9 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest } from '@/lib/api/audiobook'
|
||||
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 } from '@/lib/api'
|
||||
import apiClient, { formatApiError, adminApi, authApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
function LazyAudioPlayer({ audioUrl, jobId }: { audioUrl: string; jobId: number }) {
|
||||
@@ -699,6 +699,240 @@ function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose:
|
||||
)
|
||||
}
|
||||
|
||||
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 [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); 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 (!genre) { toast.error('请选择故事类型'); return }
|
||||
setGeneratingSynopsis(true)
|
||||
try {
|
||||
const result = await audiobookApi.generateNsfwSynopsis({
|
||||
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.createNsfwScript({
|
||||
title,
|
||||
genre: subgenre ? `${genre} - ${subgenre}` : genre,
|
||||
subgenre,
|
||||
premise: synopsis,
|
||||
style: tone,
|
||||
num_characters: numCharacters,
|
||||
num_chapters: numChapters,
|
||||
} 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">
|
||||
<Flame className="h-4 w-4 text-orange-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 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)
|
||||
@@ -746,22 +980,26 @@ function ProjectListSidebar({
|
||||
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 (
|
||||
@@ -785,6 +1023,11 @@ function ProjectListSidebar({
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasNsfwAccess && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-orange-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
|
||||
<Flame 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>
|
||||
@@ -1513,8 +1756,10 @@ export default function Audiobook() {
|
||||
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)
|
||||
@@ -1546,6 +1791,7 @@ export default function Audiobook() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
authApi.getNsfwAccess().then(r => setHasNsfwAccess(r.has_access)).catch(() => {})
|
||||
}, [fetchProjects])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1897,13 +2143,15 @@ export default function Audiobook() {
|
||||
setGeneratingChapterIndices(new Set())
|
||||
}
|
||||
}}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false) }}
|
||||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false) }}
|
||||
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 />
|
||||
@@ -1911,6 +2159,7 @@ export default function Audiobook() {
|
||||
<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 />
|
||||
|
||||
Reference in New Issue
Block a user