feat: add NSFW script generation feature and Grok API configuration

This commit is contained in:
2026-03-13 12:58:28 +08:00
parent 424c3edf0b
commit 0d63d0e6d1
28 changed files with 850 additions and 36 deletions

View File

@@ -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 />