feat: add synopsis generation endpoint and frontend integration

This commit is contained in:
2026-03-13 11:42:30 +08:00
parent 35bf7a302a
commit 6eb521dee4
5 changed files with 360 additions and 52 deletions

View File

@@ -24,6 +24,7 @@ from schemas.audiobook import (
AudiobookGenerateRequest, AudiobookGenerateRequest,
AudiobookAnalyzeRequest, AudiobookAnalyzeRequest,
ScriptGenerationRequest, ScriptGenerationRequest,
SynopsisGenerationRequest,
) )
from core.config import settings from core.config import settings
@@ -152,6 +153,47 @@ async def list_projects(
return [_project_to_response(p) for p in projects] return [_project_to_response(p) for p in projects]
@router.post("/projects/generate-synopsis")
async def generate_synopsis(
data: SynopsisGenerationRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
from db.crud import get_system_setting
if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"):
raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.")
from core.audiobook_service import _get_llm_service
llm = _get_llm_service(db)
system_prompt = (
"你是一位专业的小说策划师,擅长根据创作参数生成引人入胜的故事简介。"
"请根据用户提供的类型、风格、主角、冲突等参数生成一段200-400字的中文故事简介。"
"简介需涵盖:世界观背景、主角基本情况、核心矛盾冲突、故事基调。"
"直接输出简介正文,不要加任何前缀标题或说明文字。"
)
parts = [f"类型:{data.genre}"]
if data.subgenre:
parts.append(f"子类型:{data.subgenre}")
if data.protagonist_type:
parts.append(f"主角类型:{data.protagonist_type}")
if data.tone:
parts.append(f"故事基调:{data.tone}")
if data.conflict_scale:
parts.append(f"冲突规模:{data.conflict_scale}")
parts.append(f"角色数量:约{data.num_characters}个主要角色")
parts.append(f"故事体量:约{data.num_chapters}")
user_message = "\n".join(parts) + "\n\n请生成故事简介:"
try:
synopsis = await llm.chat(system_prompt, user_message)
except Exception as e:
logger.error(f"Synopsis generation failed: {e}")
raise HTTPException(status_code=500, detail=f"LLM generation failed: {str(e)}")
return {"synopsis": synopsis}
@router.post("/projects/generate-script", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED) @router.post("/projects/generate-script", response_model=AudiobookProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_ai_script_project( async def create_ai_script_project(
data: ScriptGenerationRequest, data: ScriptGenerationRequest,

View File

@@ -347,6 +347,7 @@ async def generate_ai_script_chapters(project_id: int, user: User, db: Session)
key = str(project_id) key = str(project_id)
ps.reset(key) ps.reset(key)
crud.update_audiobook_project_status(db, project_id, "analyzing")
cfg = project.script_config cfg = project.script_config
try: try:

View File

@@ -9,6 +9,16 @@ class AudiobookProjectCreate(BaseModel):
source_text: Optional[str] = None source_text: Optional[str] = None
class SynopsisGenerationRequest(BaseModel):
genre: str
subgenre: str = ""
protagonist_type: str = ""
tone: str = ""
conflict_scale: str = ""
num_characters: int = 5
num_chapters: int = 8
class ScriptGenerationRequest(BaseModel): class ScriptGenerationRequest(BaseModel):
title: str title: str
genre: str genre: str

View File

@@ -1,5 +1,15 @@
import apiClient from '@/lib/api' import apiClient from '@/lib/api'
export interface SynopsisGenerationRequest {
genre: string
subgenre?: string
protagonist_type?: string
tone?: string
conflict_scale?: string
num_characters?: number
num_chapters?: number
}
export interface ScriptGenerationRequest { export interface ScriptGenerationRequest {
title: string title: string
genre: string genre: string
@@ -69,6 +79,11 @@ export interface LLMConfig {
} }
export const audiobookApi = { export const audiobookApi = {
generateSynopsis: async (data: SynopsisGenerationRequest): Promise<string> => {
const response = await apiClient.post<{ synopsis: string }>('/audiobook/projects/generate-synopsis', data)
return response.data.synopsis
},
createAIScript: async (data: ScriptGenerationRequest): Promise<AudiobookProject> => { createAIScript: async (data: ScriptGenerationRequest): Promise<AudiobookProject> => {
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script', data) const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script', data)
return response.data return response.data

View File

@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Navbar } from '@/components/Navbar' import { Navbar } from '@/components/Navbar'
import { AudioPlayer } from '@/components/AudioPlayer' 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 } from '@/lib/api/audiobook'
import { RotateCcw } from 'lucide-react'
import apiClient, { formatApiError, adminApi } from '@/lib/api' import apiClient, { formatApiError, adminApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
@@ -352,34 +353,231 @@ function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onCl
) )
} }
const GENRE_OPTIONS = ['玄幻', '武侠', '仙侠', '现代言情', '都市', '悬疑', '科幻', '历史', '恐怖'] 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 }) { function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [genre, setGenre] = useState('玄幻') const [genre, setGenre] = useState('')
const [subgenre, setSubgenre] = useState('') const [subgenre, setSubgenre] = useState('')
const [premise, setPremise] = useState('') const [protagonistType, setProtagonistType] = useState('')
const [style, setStyle] = useState('') const [tone, setTone] = useState('')
const [conflictScale, setConflictScale] = useState('')
const [numCharacters, setNumCharacters] = useState(5) const [numCharacters, setNumCharacters] = useState(5)
const [numChapters, setNumChapters] = useState(8) const [numChapters, setNumChapters] = useState(8)
const [synopsis, setSynopsis] = useState('')
const [generatingSynopsis, setGeneratingSynopsis] = useState(false)
const [loading, setLoading] = 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 = () => { const reset = () => {
setTitle(''); setGenre('玄幻'); setSubgenre(''); setPremise(''); setStyle('') setTitle(''); setGenre(''); setSubgenre(''); setProtagonistType(''); setTone('')
setNumCharacters(5); setNumChapters(8) 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.generateSynopsis({
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 () => { const handleCreate = async () => {
if (!title) { toast.error('请输入作品标题'); return } if (!title) { toast.error('请输入作品标题'); return }
if (!premise) { toast.error('请输入故事简介'); return } if (!synopsis) { toast.error('请先生成故事简介'); return }
setLoading(true) setLoading(true)
try { try {
await audiobookApi.createAIScript({ await audiobookApi.createAIScript({
title, title,
genre, genre: subgenre ? `${genre} - ${subgenre}` : genre,
subgenre, subgenre,
premise, premise: synopsis,
style, style: tone,
num_characters: numCharacters, num_characters: numCharacters,
num_chapters: numChapters, num_chapters: numChapters,
} as ScriptGenerationRequest) } as ScriptGenerationRequest)
@@ -396,59 +594,101 @@ function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose:
return ( return (
<Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}> <Dialog open={open} onOpenChange={v => { if (!v) { reset(); onClose() } }}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader className="shrink-0">
<DialogTitle>AI </DialogTitle> <DialogTitle>AI </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 pt-1"> <div className="flex-1 overflow-y-auto space-y-4 pr-1">
<Input placeholder="作品标题" value={title} onChange={e => setTitle(e.target.value)} /> <div className="space-y-1">
<div className="flex gap-2"> <p className="text-xs text-muted-foreground"></p>
<select <Input placeholder="输入作品标题" value={title} onChange={e => setTitle(e.target.value)} />
className="flex-1 h-9 rounded-md border border-input bg-background px-3 py-1 text-sm"
value={genre}
onChange={e => setGenre(e.target.value)}
>
{GENRE_OPTIONS.map(g => <option key={g} value={g}>{g}</option>)}
</select>
<Input
className="flex-1"
placeholder="子类型(可选,如:升级流)"
value={subgenre}
onChange={e => setSubgenre(e.target.value)}
/>
</div> </div>
<Textarea
placeholder="故事简介(描述世界观、主角、核心冲突等)" <div className="space-y-2">
rows={4} <p className="text-xs text-muted-foreground"></p>
value={premise} <div className="flex flex-wrap gap-1.5">
onChange={e => setPremise(e.target.value)} {genreKeys.map(g => (
/> <Chip key={g} label={GENRE_CONFIGS[g].label} selected={genre === g} onClick={() => handleGenreSelect(g)} />
<Input placeholder="写作风格(可选,如:热血、轻松幽默、黑暗沉郁)" value={style} onChange={e => setStyle(e.target.value)} /> ))}
</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"> <div className="flex gap-3">
<label className="flex-1 flex flex-col gap-1 text-sm"> <label className="flex-1 flex flex-col gap-1 text-sm">
<span className="text-muted-foreground text-xs">2-10</span> <span className="text-muted-foreground text-xs">2-10</span>
<Input <Input type="number" min={2} max={10} value={numCharacters} onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))} />
type="number"
min={2}
max={10}
value={numCharacters}
onChange={e => setNumCharacters(Math.min(10, Math.max(2, Number(e.target.value))))}
/>
</label> </label>
<label className="flex-1 flex flex-col gap-1 text-sm"> <label className="flex-1 flex flex-col gap-1 text-sm">
<span className="text-muted-foreground text-xs">2-30</span> <span className="text-muted-foreground text-xs">2-30</span>
<Input <Input type="number" min={2} max={30} value={numChapters} onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))} />
type="number"
min={2}
max={30}
value={numChapters}
onChange={e => setNumChapters(Math.min(30, Math.max(2, Number(e.target.value))))}
/>
</label> </label>
</div> </div>
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => { reset(); onClose() }} disabled={loading}></Button> <div className="flex justify-end">
<Button size="sm" onClick={handleCreate} disabled={loading}> <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 ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
{loading ? '创建中...' : '生成剧本'} {loading ? '创建中...' : '生成剧本'}
</Button> </Button>