Compare commits
4 Commits
444dcb8bcf
...
7644584c39
| Author | SHA1 | Date | |
|---|---|---|---|
| 7644584c39 | |||
| 7129047c3f | |||
| 6eb521dee4 | |||
| 35bf7a302a |
@@ -23,6 +23,9 @@ from schemas.audiobook import (
|
||||
AudiobookSegmentUpdate,
|
||||
AudiobookGenerateRequest,
|
||||
AudiobookAnalyzeRequest,
|
||||
ScriptGenerationRequest,
|
||||
SynopsisGenerationRequest,
|
||||
ContinueScriptRequest,
|
||||
)
|
||||
from core.config import settings
|
||||
|
||||
@@ -39,6 +42,7 @@ def _project_to_response(project) -> AudiobookProjectResponse:
|
||||
status=project.status,
|
||||
llm_model=project.llm_model,
|
||||
error_message=project.error_message,
|
||||
script_config=getattr(project, 'script_config', None),
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
@@ -150,6 +154,120 @@ async def list_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)
|
||||
async def create_ai_script_project(
|
||||
data: ScriptGenerationRequest,
|
||||
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.")
|
||||
|
||||
project = crud.create_audiobook_project(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
title=data.title,
|
||||
source_type="ai_generated",
|
||||
script_config=data.model_dump(),
|
||||
)
|
||||
|
||||
from core.audiobook_service import generate_ai_script
|
||||
from core.database import SessionLocal
|
||||
|
||||
project_id = project.id
|
||||
user_id = current_user.id
|
||||
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, user_id)
|
||||
await generate_ai_script(project_id, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run())
|
||||
return _project_to_response(project)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/continue-script")
|
||||
async def continue_script(
|
||||
project_id: int,
|
||||
data: ContinueScriptRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
project = crud.get_audiobook_project(db, project_id, current_user.id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.source_type != "ai_generated":
|
||||
raise HTTPException(status_code=400, detail="Only AI-generated projects support this operation")
|
||||
if project.status not in ("ready", "done", "error"):
|
||||
raise HTTPException(status_code=400, detail=f"Project must be in 'ready' or 'done' state, current: {project.status}")
|
||||
|
||||
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 continue_ai_script_chapters
|
||||
from core.database import SessionLocal
|
||||
|
||||
additional_chapters = max(1, min(20, data.additional_chapters))
|
||||
user_id = current_user.id
|
||||
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, user_id)
|
||||
await continue_ai_script_chapters(project_id, additional_chapters, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"message": f"Continuing script generation ({additional_chapters} chapters)", "project_id": project_id}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=AudiobookProjectDetail)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
@@ -207,6 +325,23 @@ async def confirm_characters(
|
||||
if project.status != "characters_ready":
|
||||
raise HTTPException(status_code=400, detail="Project must be in 'characters_ready' state to confirm characters")
|
||||
|
||||
if project.source_type == "ai_generated":
|
||||
from core.audiobook_service import generate_ai_script_chapters
|
||||
from core.database import SessionLocal
|
||||
|
||||
user_id = current_user.id
|
||||
|
||||
async def run():
|
||||
async_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(async_db, user_id)
|
||||
await generate_ai_script_chapters(project_id, db_user, async_db)
|
||||
finally:
|
||||
async_db.close()
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"message": "Script generation started", "project_id": project_id}
|
||||
|
||||
from core.audiobook_service import identify_chapters
|
||||
try:
|
||||
identify_chapters(project_id, db, project)
|
||||
|
||||
@@ -15,6 +15,9 @@ from db.models import AudiobookProject, AudiobookCharacter, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LINE_RE = re.compile(r'^【(.+?)】(.*)$')
|
||||
_EMO_RE = re.compile(r'((开心|愤怒|悲伤|恐惧|厌恶|低沉|惊讶):([0-9.]+))\s*$')
|
||||
|
||||
# Cancellation events for batch operations, keyed by project_id
|
||||
_cancel_events: dict[int, asyncio.Event] = {}
|
||||
|
||||
@@ -161,6 +164,497 @@ def _split_into_chapters(text: str) -> list[str]:
|
||||
return chapters
|
||||
|
||||
|
||||
def parse_ai_script(script_text: str, char_map: dict) -> list[dict]:
|
||||
results = []
|
||||
for raw_line in script_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = _LINE_RE.match(line)
|
||||
if not m:
|
||||
if results:
|
||||
results[-1]["text"] = results[-1]["text"] + " " + line
|
||||
continue
|
||||
speaker = m.group(1).strip()
|
||||
content = m.group(2).strip()
|
||||
|
||||
emo_text = None
|
||||
emo_alpha = None
|
||||
emo_m = _EMO_RE.search(content)
|
||||
if emo_m:
|
||||
emo_text = emo_m.group(1)
|
||||
try:
|
||||
emo_alpha = float(emo_m.group(2))
|
||||
except ValueError:
|
||||
emo_alpha = None
|
||||
content = content[:emo_m.start()].strip()
|
||||
|
||||
if content.startswith('"') and content.endswith('"'):
|
||||
content = content[1:-1].strip()
|
||||
elif content.startswith('"') and content.endswith('"'):
|
||||
content = content[1:-1].strip()
|
||||
|
||||
character = speaker
|
||||
|
||||
results.append({
|
||||
"character": character,
|
||||
"text": content,
|
||||
"emo_text": emo_text,
|
||||
"emo_alpha": emo_alpha,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
async def generate_ai_script(project_id: int, user: User, db: Session) -> None:
|
||||
from core.database import SessionLocal
|
||||
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project or not project.script_config:
|
||||
return
|
||||
|
||||
key = str(project_id)
|
||||
ps.reset(key)
|
||||
cfg = project.script_config
|
||||
|
||||
try:
|
||||
crud.update_audiobook_project_status(db, project_id, "analyzing")
|
||||
ps.append_line(key, f"[AI剧本] 项目「{project.title}」开始生成剧本")
|
||||
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="ai_script_generate")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
genre = cfg.get("genre", "")
|
||||
subgenre = cfg.get("subgenre", "")
|
||||
premise = cfg.get("premise", "")
|
||||
style = cfg.get("style", "")
|
||||
num_characters = cfg.get("num_characters", 5)
|
||||
num_chapters = cfg.get("num_chapters", 8)
|
||||
|
||||
ps.append_line(key, f"\n[Step 1] 生成 {num_characters} 个角色...\n")
|
||||
ps.append_line(key, "")
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(key, token)
|
||||
|
||||
characters_data = await llm.generate_story_characters(
|
||||
genre=genre, subgenre=subgenre, premise=premise, style=style,
|
||||
num_characters=num_characters, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
has_narrator = any(c.get("name") in ("narrator", "旁白") for c in characters_data)
|
||||
if not has_narrator:
|
||||
characters_data.insert(0, {
|
||||
"name": "旁白",
|
||||
"gender": "未知",
|
||||
"description": "第三人称旁白叙述者",
|
||||
"instruct": (
|
||||
"音色信息:浑厚醇厚的男性中低音,嗓音饱满有力,带有传统说书人的磁性与感染力\n"
|
||||
"身份背景:中国传统说书艺人,精通评书、章回小说叙述艺术,深谙故事节奏与听众心理\n"
|
||||
"年龄设定:中年男性,四五十岁,声音历经岁月沉淀,成熟稳重而不失活力\n"
|
||||
"外貌特征:面容沉稳,气度从容,台风大气,给人以可信赖的叙述者印象\n"
|
||||
"性格特质:沉稳睿智,叙事冷静客观,情到深处能引发共鸣,不动声色间娓娓道来\n"
|
||||
"叙事风格:语速适中偏慢,抑扬顿挫,擅长铺垫悬念,停顿恰到好处,语气庄重而生动,富有画面感"
|
||||
)
|
||||
})
|
||||
|
||||
ps.append_line(key, f"\n\n[完成] 角色列表:{', '.join(c.get('name', '') for c in characters_data)}")
|
||||
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
crud.delete_audiobook_characters(db, project_id)
|
||||
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
for char_data in characters_data:
|
||||
name = char_data.get("name", "旁白")
|
||||
if name == "narrator":
|
||||
name = "旁白"
|
||||
instruct = char_data.get("instruct", "")
|
||||
description = char_data.get("description", "")
|
||||
gender = char_data.get("gender") or ("未知" if name == "旁白" else None)
|
||||
try:
|
||||
voice_design = crud.create_voice_design(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
name=f"[有声书] {project.title} - {name}",
|
||||
instruct=instruct,
|
||||
backend_type=backend_type,
|
||||
preview_text=description[:100] if description else None,
|
||||
)
|
||||
crud.create_audiobook_character(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
gender=gender,
|
||||
description=description,
|
||||
instruct=instruct,
|
||||
voice_design_id=voice_design.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create char/voice for {name}: {e}")
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "characters_ready")
|
||||
ps.append_line(key, f"\n[状态] 角色创建完成,请确认角色后继续生成剧本")
|
||||
ps.mark_done(key)
|
||||
|
||||
user_id = user.id
|
||||
|
||||
async def _generate_all_previews():
|
||||
temp_db = SessionLocal()
|
||||
try:
|
||||
characters = crud.list_audiobook_characters(temp_db, project_id)
|
||||
char_ids = [c.id for c in characters]
|
||||
finally:
|
||||
temp_db.close()
|
||||
if not char_ids:
|
||||
return
|
||||
sem = asyncio.Semaphore(3)
|
||||
async def _gen(char_id: int):
|
||||
async with sem:
|
||||
local_db = SessionLocal()
|
||||
try:
|
||||
db_user = crud.get_user_by_id(local_db, user_id)
|
||||
await generate_character_preview(project_id, char_id, db_user, local_db)
|
||||
except Exception as e:
|
||||
logger.error(f"Background preview failed for char {char_id}: {e}")
|
||||
finally:
|
||||
local_db.close()
|
||||
await asyncio.gather(*[_gen(cid) for cid in char_ids])
|
||||
|
||||
asyncio.create_task(_generate_all_previews())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"generate_ai_script failed for project {project_id}: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
ps.mark_done(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def generate_ai_script_chapters(project_id: int, user: User, db: Session) -> None:
|
||||
from core.database import SessionLocal
|
||||
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project or not project.script_config:
|
||||
return
|
||||
|
||||
key = str(project_id)
|
||||
ps.reset(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "analyzing")
|
||||
cfg = project.script_config
|
||||
|
||||
try:
|
||||
genre = cfg.get("genre", "")
|
||||
subgenre = cfg.get("subgenre", "")
|
||||
premise = cfg.get("premise", "")
|
||||
style = cfg.get("style", "")
|
||||
num_chapters = cfg.get("num_chapters", 8)
|
||||
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="ai_script_chapters")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(key, token)
|
||||
|
||||
db_characters = crud.list_audiobook_characters(db, project_id)
|
||||
characters_data = [
|
||||
{"name": c.name, "gender": c.gender or "未知", "description": c.description or "", "instruct": c.instruct or ""}
|
||||
for c in db_characters
|
||||
]
|
||||
char_map = {c.name: c for c in db_characters}
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
ps.append_line(key, f"[AI剧本] 开始生成 {num_chapters} 章大纲...\n")
|
||||
ps.append_line(key, "")
|
||||
|
||||
chapters_data = await llm.generate_chapter_outline(
|
||||
genre=genre, subgenre=subgenre, premise=premise, style=style,
|
||||
num_chapters=num_chapters, characters=characters_data, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
ps.append_line(key, f"\n\n[完成] 大纲:{len(chapters_data)} 章")
|
||||
|
||||
crud.delete_audiobook_chapters(db, project_id)
|
||||
crud.delete_audiobook_segments(db, project_id)
|
||||
|
||||
project_audio_dir = Path(settings.OUTPUT_DIR) / "audiobook" / str(project_id)
|
||||
for subdir in ("segments", "chapters"):
|
||||
d = project_audio_dir / subdir
|
||||
if d.exists():
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
for ch_data in chapters_data:
|
||||
idx = ch_data.get("index", 0)
|
||||
title = ch_data.get("title", f"第 {idx + 1} 章")
|
||||
summary = ch_data.get("summary", "")
|
||||
crud.create_audiobook_chapter(db, project_id, idx, summary, title=title)
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
|
||||
ps.append_line(key, f"\n[Step 2] 逐章生成对话脚本...\n")
|
||||
|
||||
for ch_data in chapters_data:
|
||||
idx = ch_data.get("index", 0)
|
||||
title = ch_data.get("title", f"第 {idx + 1} 章")
|
||||
summary = ch_data.get("summary", "")
|
||||
|
||||
ps.append_line(key, f"\n第 {idx + 1} 章「{title}」→ ")
|
||||
ps.append_line(key, "")
|
||||
|
||||
chapter_obj = crud.get_audiobook_chapter_by_index(db, project_id, idx)
|
||||
if not chapter_obj:
|
||||
continue
|
||||
|
||||
try:
|
||||
script_text = await llm.generate_chapter_script(
|
||||
genre=genre, premise=premise,
|
||||
chapter_index=idx, chapter_title=title, chapter_summary=summary,
|
||||
characters=characters_data, on_token=on_token, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
chapter_obj.source_text = script_text
|
||||
db.commit()
|
||||
|
||||
segments_data = parse_ai_script(script_text, char_map)
|
||||
|
||||
unknown_speakers = {
|
||||
seg["character"] for seg in segments_data
|
||||
if seg["character"] != "旁白" and seg["character"] not in char_map
|
||||
}
|
||||
for speaker_name in sorted(unknown_speakers):
|
||||
try:
|
||||
npc_instruct = (
|
||||
"音色信息:普通自然的中性成年人声音,语调平和\n"
|
||||
"身份背景:故事中的路人或配角\n"
|
||||
"年龄设定:成年人\n"
|
||||
"外貌特征:普通外貌\n"
|
||||
"性格特质:平淡自然\n"
|
||||
"叙事风格:语速正常,语气自然"
|
||||
)
|
||||
npc_voice = crud.create_voice_design(
|
||||
db=db, user_id=user.id,
|
||||
name=f"[有声书] {project.title} - {speaker_name}",
|
||||
instruct=npc_instruct, backend_type=backend_type,
|
||||
)
|
||||
npc_char = crud.create_audiobook_character(
|
||||
db=db, project_id=project_id, name=speaker_name,
|
||||
description=f"配角:{speaker_name}",
|
||||
instruct=npc_instruct, voice_design_id=npc_voice.id,
|
||||
)
|
||||
char_map[speaker_name] = npc_char
|
||||
ps.append_line(key, f"\n[NPC] 自动创建配角:{speaker_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create NPC {speaker_name}: {e}")
|
||||
|
||||
crud.delete_audiobook_segments_for_chapter(db, project_id, idx)
|
||||
|
||||
seg_counter = 0
|
||||
for seg in segments_data:
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白")
|
||||
if not char:
|
||||
continue
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, char.id, seg_text,
|
||||
chapter_index=idx, segment_index=seg_counter,
|
||||
emo_text=seg.get("emo_text"), emo_alpha=seg.get("emo_alpha"),
|
||||
)
|
||||
seg_counter += 1
|
||||
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "ready")
|
||||
ps.append_line(key, f"\n✓ {seg_counter} 段")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chapter {idx} script generation failed: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "error", error_message=str(e))
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
ps.append_line(key, f"\n\n[完成] AI剧本生成完毕,项目已就绪")
|
||||
ps.mark_done(key)
|
||||
logger.info(f"AI script chapters generation complete for project {project_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"generate_ai_script_chapters failed for project {project_id}: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
ps.mark_done(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def continue_ai_script_chapters(project_id: int, additional_chapters: int, user: User, db: Session) -> None:
|
||||
from core.database import SessionLocal
|
||||
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project or not project.script_config:
|
||||
return
|
||||
|
||||
key = str(project_id)
|
||||
ps.reset(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "generating")
|
||||
cfg = project.script_config
|
||||
|
||||
try:
|
||||
genre = cfg.get("genre", "")
|
||||
subgenre = cfg.get("subgenre", "")
|
||||
premise = cfg.get("premise", "")
|
||||
style = cfg.get("style", "")
|
||||
|
||||
llm = _get_llm_service(db)
|
||||
_llm_model = crud.get_system_setting(db, "llm_model")
|
||||
_user_id = user.id
|
||||
|
||||
def _log_usage(prompt_tokens: int, completion_tokens: int) -> None:
|
||||
log_db = SessionLocal()
|
||||
try:
|
||||
crud.create_usage_log(log_db, _user_id, prompt_tokens, completion_tokens,
|
||||
model=_llm_model, context="ai_script_continue")
|
||||
finally:
|
||||
log_db.close()
|
||||
|
||||
def on_token(token: str) -> None:
|
||||
ps.append_token(key, token)
|
||||
|
||||
db_characters = crud.list_audiobook_characters(db, project_id)
|
||||
characters_data = [
|
||||
{"name": c.name, "gender": c.gender or "未知", "description": c.description or "", "instruct": c.instruct or ""}
|
||||
for c in db_characters
|
||||
]
|
||||
char_map = {c.name: c for c in db_characters}
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
existing_chapters = crud.list_audiobook_chapters(db, project_id)
|
||||
existing_chapters_data = [
|
||||
{"index": ch.chapter_index, "title": ch.title or f"第{ch.chapter_index + 1}章", "summary": ""}
|
||||
for ch in existing_chapters
|
||||
]
|
||||
start_index = max((ch.chapter_index for ch in existing_chapters), default=-1) + 1
|
||||
|
||||
ps.append_line(key, f"[AI剧本] 续写 {additional_chapters} 章,从第 {start_index + 1} 章开始...\n")
|
||||
ps.append_line(key, "")
|
||||
|
||||
new_chapters_data = await llm.generate_additional_chapter_outline(
|
||||
genre=genre, subgenre=subgenre, premise=premise, style=style,
|
||||
existing_chapters=existing_chapters_data, additional_chapters=additional_chapters,
|
||||
characters=characters_data, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
ps.append_line(key, f"\n\n[完成] 续写大纲:{len(new_chapters_data)} 章")
|
||||
|
||||
assigned = []
|
||||
for offset, ch_data in enumerate(new_chapters_data):
|
||||
idx = start_index + offset
|
||||
title = ch_data.get("title", f"第 {idx + 1} 章")
|
||||
summary = ch_data.get("summary", "")
|
||||
crud.create_audiobook_chapter(db, project_id, idx, summary, title=title)
|
||||
assigned.append((idx, title, summary))
|
||||
|
||||
ps.append_line(key, f"\n[Step 2] 逐章生成对话脚本...\n")
|
||||
|
||||
for idx, title, summary in assigned:
|
||||
|
||||
ps.append_line(key, f"\n第 {idx + 1} 章「{title}」→ ")
|
||||
ps.append_line(key, "")
|
||||
|
||||
chapter_obj = crud.get_audiobook_chapter_by_index(db, project_id, idx)
|
||||
if not chapter_obj:
|
||||
continue
|
||||
|
||||
try:
|
||||
script_text = await llm.generate_chapter_script(
|
||||
genre=genre, premise=premise,
|
||||
chapter_index=idx, chapter_title=title, chapter_summary=summary,
|
||||
characters=characters_data, on_token=on_token, usage_callback=_log_usage,
|
||||
)
|
||||
|
||||
chapter_obj.source_text = script_text
|
||||
db.commit()
|
||||
|
||||
segments_data = parse_ai_script(script_text, char_map)
|
||||
|
||||
unknown_speakers = {
|
||||
seg["character"] for seg in segments_data
|
||||
if seg["character"] != "旁白" and seg["character"] not in char_map
|
||||
}
|
||||
for speaker_name in sorted(unknown_speakers):
|
||||
try:
|
||||
npc_instruct = (
|
||||
"音色信息:普通自然的中性成年人声音,语调平和\n"
|
||||
"身份背景:故事中的路人或配角\n"
|
||||
"年龄设定:成年人\n"
|
||||
"外貌特征:普通外貌\n"
|
||||
"性格特质:平淡自然\n"
|
||||
"叙事风格:语速正常,语气自然"
|
||||
)
|
||||
npc_voice = crud.create_voice_design(
|
||||
db=db, user_id=user.id,
|
||||
name=f"[有声书] {project.title} - {speaker_name}",
|
||||
instruct=npc_instruct, backend_type=backend_type,
|
||||
)
|
||||
npc_char = crud.create_audiobook_character(
|
||||
db=db, project_id=project_id, name=speaker_name,
|
||||
description=f"配角:{speaker_name}",
|
||||
instruct=npc_instruct, voice_design_id=npc_voice.id,
|
||||
)
|
||||
char_map[speaker_name] = npc_char
|
||||
ps.append_line(key, f"\n[NPC] 自动创建配角:{speaker_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create NPC {speaker_name}: {e}")
|
||||
|
||||
crud.delete_audiobook_segments_for_chapter(db, project_id, idx)
|
||||
|
||||
seg_counter = 0
|
||||
for seg in segments_data:
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白")
|
||||
if not char:
|
||||
continue
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, char.id, seg_text,
|
||||
chapter_index=idx, segment_index=seg_counter,
|
||||
emo_text=seg.get("emo_text"), emo_alpha=seg.get("emo_alpha"),
|
||||
)
|
||||
seg_counter += 1
|
||||
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "ready")
|
||||
ps.append_line(key, f"\n✓ {seg_counter} 段")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chapter {idx} script generation failed: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
crud.update_audiobook_chapter_status(db, chapter_obj.id, "error", error_message=str(e))
|
||||
|
||||
crud.update_audiobook_project_status(db, project_id, "ready")
|
||||
ps.append_line(key, f"\n\n[完成] 续写 {len(assigned)} 章完毕,项目已就绪")
|
||||
ps.mark_done(key)
|
||||
logger.info(f"continue_ai_script_chapters complete for project {project_id}, added {len(assigned)} chapters")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"continue_ai_script_chapters failed for project {project_id}: {e}", exc_info=True)
|
||||
ps.append_line(key, f"\n[错误] {e}")
|
||||
ps.mark_done(key)
|
||||
crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e))
|
||||
|
||||
|
||||
async def analyze_project(project_id: int, user: User, db: Session, turbo: bool = False) -> None:
|
||||
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||
if not project:
|
||||
@@ -242,10 +736,10 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
usage_callback=_log_analyze_usage,
|
||||
)
|
||||
|
||||
has_narrator = any(c.get("name") == "narrator" for c in characters_data)
|
||||
has_narrator = any(c.get("name") in ("narrator", "旁白") for c in characters_data)
|
||||
if not has_narrator:
|
||||
characters_data.insert(0, {
|
||||
"name": "narrator",
|
||||
"name": "旁白",
|
||||
"gender": "未知",
|
||||
"description": "第三人称旁白叙述者",
|
||||
"instruct": (
|
||||
@@ -266,10 +760,12 @@ async def analyze_project(project_id: int, user: User, db: Session, turbo: bool
|
||||
backend_type = user.user_preferences.get("default_backend", "aliyun") if user.user_preferences else "aliyun"
|
||||
|
||||
for char_data in characters_data:
|
||||
name = char_data.get("name", "narrator")
|
||||
name = char_data.get("name", "旁白")
|
||||
if name == "narrator":
|
||||
name = "旁白"
|
||||
instruct = char_data.get("instruct", "")
|
||||
description = char_data.get("description", "")
|
||||
gender = char_data.get("gender") or ("未知" if name == "narrator" else None)
|
||||
gender = char_data.get("gender") or ("未知" if name == "旁白" else None)
|
||||
try:
|
||||
voice_design = crud.create_voice_design(
|
||||
db=db,
|
||||
@@ -443,7 +939,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
ps.append_line(key, f"\n[回退] {e}")
|
||||
failed_chunks += 1
|
||||
last_error = str(e)
|
||||
narrator = char_map.get("narrator")
|
||||
narrator = char_map.get("旁白") or char_map.get("narrator")
|
||||
if narrator:
|
||||
crud.create_audiobook_segment(
|
||||
db, project_id, narrator.id, chunk.strip(),
|
||||
@@ -457,7 +953,7 @@ async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) ->
|
||||
seg_text = seg.get("text", "").strip()
|
||||
if not seg_text:
|
||||
continue
|
||||
char = char_map.get(seg.get("character", "narrator")) or char_map.get("narrator")
|
||||
char = char_map.get(seg.get("character", "旁白")) or char_map.get("旁白") or char_map.get("narrator")
|
||||
if not char:
|
||||
continue
|
||||
seg_emo_text = seg.get("emo_text", "") or None
|
||||
@@ -834,8 +1330,6 @@ async def generate_character_preview(project_id: int, char_id: int, user: User,
|
||||
audio_path = output_base / f"char_{char_id}.wav"
|
||||
|
||||
preview_name = char.name
|
||||
if preview_name == "narrator":
|
||||
preview_name = "旁白"
|
||||
|
||||
preview_desc = ""
|
||||
if char.description:
|
||||
|
||||
@@ -212,19 +212,166 @@ class LLMService:
|
||||
seen[name] = c
|
||||
return list(seen.values())
|
||||
|
||||
async def generate_story_characters(
|
||||
self,
|
||||
genre: str,
|
||||
subgenre: str,
|
||||
premise: str,
|
||||
style: str,
|
||||
num_characters: int,
|
||||
usage_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> list[Dict]:
|
||||
genre_label = f"{genre}{'/' + subgenre if subgenre else ''}"
|
||||
system_prompt = (
|
||||
"你是一个专业的故事创作助手兼声音导演。请根据给定的故事信息,创作角色列表(包含旁白narrator)。\n"
|
||||
"gender字段必须明确标注性别,只能取以下三个值之一:\"男\"、\"女\"、\"未知\"。\n"
|
||||
"narrator的gender固定为\"未知\"。\n"
|
||||
"对每个角色,instruct字段必须是详细的声音导演说明,需覆盖以下六个维度,每个维度单独一句,用换行分隔:\n"
|
||||
"1. 音色信息:嗓音质感、音域、音量、气息特征(女性角色必须以'女性声音'开头;男性角色则以'男性声音'开头)\n"
|
||||
"2. 身份背景:角色身份、职业、出身、所处时代背景对声音的影响\n"
|
||||
"3. 年龄设定:具体年龄段及其在声音上的体现\n"
|
||||
"4. 外貌特征:体型、面容、精神状态等可影响声音感知的特征\n"
|
||||
"5. 性格特质:核心性格、情绪模式、表达习惯\n"
|
||||
"6. 叙事风格:语速节奏、停顿习惯、语气色彩、整体叙述感\n\n"
|
||||
"注意:instruct 的第一行(音色信息)必须与 gender 字段保持一致。\n\n"
|
||||
"【特别规定】narrator(旁白)的 instruct 必须根据小说类型选择对应的叙述者音色风格,规则如下:\n"
|
||||
"▸ 古风/武侠/历史/玄幻/仙侠/奇幻 → 传统说书人风格:浑厚醇厚的男性中低音,嗓音饱满有力,带有说书人的磁性与感染力;中年男性,四五十岁;语速适中偏慢,抑扬顿挫,停顿恰到好处,语气庄重生动,富有画面感\n"
|
||||
"▸ 现代言情/都市爱情/青春校园 → 年轻女性叙述者风格:女性声音,清亮柔和的中高音,嗓音清新干净,带有亲切温柔的娓娓道来感;二三十岁年轻女性;语速轻快自然,情感细腻,语气温柔而富有感染力\n"
|
||||
"▸ 悬疑/推理/惊悚/恐怖 → 低沉神秘风格:男性声音,低沉压抑的男性低音,嗓音干练克制,带有一丝神秘与张力;中年男性;语速沉稳偏慢,停顿制造悬念,语气冷静克制,暗藏紧张感\n"
|
||||
"▸ 科幻/末世/赛博朋克 → 理性宏观风格:男性声音,清晰有力的男性中音,嗓音冷静客观,带有纪录片解说员的宏大叙事感;语速稳定,条理清晰,语气客观宏观,富有科技感与史诗感\n"
|
||||
"▸ 其他/无法判断 → 传统说书人风格(同古风类型)\n\n"
|
||||
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||
'{"characters": [{"name": "narrator", "gender": "未知", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景:...\\n年龄设定:...\\n外貌特征:...\\n性格特质:...\\n叙事风格:..."}, ...]}'
|
||||
)
|
||||
parts = [f"故事类型:{genre_label}"]
|
||||
if style:
|
||||
parts.append(f"风格:{style}")
|
||||
parts.append(f"故事简介:{premise}")
|
||||
parts.append(f"请为这个故事创作 {num_characters} 个主要角色,再加上旁白narrator,共 {num_characters + 1} 个角色。")
|
||||
user_message = "\n".join(parts)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, max_tokens=4096, usage_callback=usage_callback)
|
||||
return result.get("characters", [])
|
||||
|
||||
async def generate_chapter_outline(
|
||||
self,
|
||||
genre: str,
|
||||
subgenre: str,
|
||||
premise: str,
|
||||
style: str,
|
||||
num_chapters: int,
|
||||
characters: list[Dict],
|
||||
usage_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的故事创作助手。请根据给定的故事信息和角色列表,创作章节大纲。\n"
|
||||
"每章包含章节索引(从0开始)、标题和简介。\n"
|
||||
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||
'{"chapters": [{"index": 0, "title": "第一章 标题", "summary": "章节内容简介,2-3句话"}, ...]}'
|
||||
)
|
||||
genre_label = f"{genre}{'/' + subgenre if subgenre else ''}"
|
||||
char_names = [c.get("name", "") for c in characters if c.get("name") not in ("narrator", "旁白")]
|
||||
user_message = (
|
||||
f"故事类型:{genre_label}\n"
|
||||
+ (f"风格:{style}\n" if style else "")
|
||||
+ f"故事简介:{premise}\n"
|
||||
f"主要角色:{', '.join(char_names)}\n"
|
||||
f"请创作 {num_chapters} 章的大纲。"
|
||||
)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, max_tokens=4096, usage_callback=usage_callback)
|
||||
return result.get("chapters", [])
|
||||
|
||||
async def generate_chapter_script(
|
||||
self,
|
||||
genre: str,
|
||||
premise: str,
|
||||
chapter_index: int,
|
||||
chapter_title: str,
|
||||
chapter_summary: str,
|
||||
characters: list[Dict],
|
||||
on_token=None,
|
||||
usage_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> str:
|
||||
char_names = [c.get("name", "") for c in characters if c.get("name") not in ("narrator", "旁白")]
|
||||
names_str = "、".join(char_names)
|
||||
system_prompt = (
|
||||
"你是一个专业的有声书剧本创作助手。请根据章节信息创作完整的对话脚本。\n\n"
|
||||
"输出格式规则(严格遵守):\n"
|
||||
"每行使用以下两种格式之一:\n"
|
||||
" 【旁白】叙述文字(情感词:强度)\n"
|
||||
" 【角色名】\"对话内容\"(情感词:强度)\n\n"
|
||||
"情感标注规则:\n"
|
||||
"- 情感词可选:开心、愤怒、悲伤、恐惧、厌恶、低沉、惊讶\n"
|
||||
"- 各情感强度上限(严格不超过):开心=0.35、愤怒=0.15、悲伤=0.1、恐惧=0.1、厌恶=0.35、低沉=0.35、惊讶=0.1\n"
|
||||
"- 情感不明显时可省略(情感词:强度)整个括号\n"
|
||||
"- 旁白叙述一般不需要情感标注\n\n"
|
||||
"其他规则:\n"
|
||||
"- 旁白使用【旁白】标记\n"
|
||||
f"- 主要角色名从以下列表选择:{names_str}\n"
|
||||
"- 若剧情需要路人/群众/配角台词,可使用简短中文描述性名称(如:路人甲、镇民、警察、店员等),不必限于主角列表\n"
|
||||
"- 对话内容使用中文引号(\"...\")包裹\n"
|
||||
"- 每行为一个独立片段,不要有空行\n"
|
||||
"- 直接输出脚本内容,不要有其他说明文字"
|
||||
)
|
||||
user_message = (
|
||||
f"故事类型:{genre}\n"
|
||||
f"故事简介:{premise}\n\n"
|
||||
f"第 {chapter_index + 1} 章:{chapter_title}\n"
|
||||
f"章节内容:{chapter_summary}\n\n"
|
||||
"请创作这一章的完整对话脚本,包含旁白叙述和角色对话,内容充实,段落自然流畅。"
|
||||
)
|
||||
return await self.stream_chat(
|
||||
system_prompt, user_message, on_token=on_token, max_tokens=4096, usage_callback=usage_callback
|
||||
)
|
||||
|
||||
async def generate_additional_chapter_outline(
|
||||
self,
|
||||
genre: str,
|
||||
subgenre: str,
|
||||
premise: str,
|
||||
style: str,
|
||||
existing_chapters: list[Dict],
|
||||
additional_chapters: int,
|
||||
characters: list[Dict],
|
||||
usage_callback: Optional[Callable[[int, int], None]] = None,
|
||||
) -> list[Dict]:
|
||||
system_prompt = (
|
||||
"你是一个专业的故事创作助手。请根据已有章节大纲,续写新的章节大纲。\n"
|
||||
"每章包含章节索引(从给定起始索引开始)、标题和简介。\n"
|
||||
"新章节必须与已有章节剧情连贯,情节有所推进。\n"
|
||||
"只输出JSON,格式如下,不要有其他文字:\n"
|
||||
'{"chapters": [{"index": N, "title": "标题", "summary": "章节内容简介,2-3句话"}, ...]}'
|
||||
)
|
||||
genre_label = f"{genre}{'/' + subgenre if subgenre else ''}"
|
||||
char_names = [c.get("name", "") for c in characters if c.get("name") not in ("narrator", "旁白")]
|
||||
start_index = len(existing_chapters)
|
||||
existing_summary = "\n".join(
|
||||
f"第{ch.get('index', i) + 1}章「{ch.get('title', '')}」:{ch.get('summary', '')}"
|
||||
for i, ch in enumerate(existing_chapters)
|
||||
)
|
||||
user_message = (
|
||||
f"故事类型:{genre_label}\n"
|
||||
+ (f"风格:{style}\n" if style else "")
|
||||
+ f"故事简介:{premise}\n"
|
||||
f"主要角色:{', '.join(char_names)}\n\n"
|
||||
f"已有章节大纲(共{len(existing_chapters)}章):\n{existing_summary}\n\n"
|
||||
f"请从第{start_index}章(索引{start_index})开始,续写{additional_chapters}章大纲,剧情要承接上文。"
|
||||
)
|
||||
result = await self.stream_chat_json(system_prompt, user_message, max_tokens=4096, usage_callback=usage_callback)
|
||||
return result.get("chapters", [])
|
||||
|
||||
async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None, usage_callback: Optional[Callable[[int, int], None]] = None) -> list[Dict]:
|
||||
names_str = "、".join(character_names)
|
||||
system_prompt = (
|
||||
"你是一个专业的有声书制作助手。请将给定的章节文本解析为对话片段列表。"
|
||||
f"已知角色列表(必须从中选择):{names_str}。"
|
||||
"所有非对话的叙述文字归属于narrator角色。\n"
|
||||
"所有非对话的叙述文字归属于旁白角色。\n"
|
||||
"同时根据语境为每个片段判断是否有明显情绪,有则设置情绪类型(emo_text)和强度(emo_alpha),无则留空。\n"
|
||||
"可选情绪:开心、愤怒、悲伤、恐惧、厌恶、低沉、惊讶。\n"
|
||||
"情绪不明显或narrator旁白时,emo_text设为\"\",emo_alpha设为0。\n"
|
||||
"情绪不明显或旁白时,emo_text设为\"\",emo_alpha设为0。\n"
|
||||
"各情绪强度上限(严格不超过):开心=0.35、愤怒=0.15、悲伤=0.1、恐惧=0.1、厌恶=0.35、低沉=0.35、惊讶=0.1。\n"
|
||||
"同一角色的连续台词,情绪应尽量保持一致或仅有微弱变化,避免相邻片段间情绪跳跃。\n"
|
||||
"只输出JSON数组,不要有其他文字,格式如下:\n"
|
||||
'[{"character": "narrator", "text": "叙述文字", "emo_text": "", "emo_alpha": 0}, '
|
||||
'[{"character": "旁白", "text": "叙述文字", "emo_text": "", "emo_alpha": 0}, '
|
||||
'{"character": "角色名", "text": "对话内容", "emo_text": "开心", "emo_alpha": 0.3}, ...]'
|
||||
)
|
||||
user_message = f"请解析以下章节文本:\n\n{chapter_text}"
|
||||
|
||||
@@ -421,6 +421,7 @@ def create_audiobook_project(
|
||||
source_text: Optional[str] = None,
|
||||
source_path: Optional[str] = None,
|
||||
llm_model: Optional[str] = None,
|
||||
script_config: Optional[Dict[str, Any]] = None,
|
||||
) -> AudiobookProject:
|
||||
project = AudiobookProject(
|
||||
user_id=user_id,
|
||||
@@ -429,6 +430,7 @@ def create_audiobook_project(
|
||||
source_text=source_text,
|
||||
source_path=source_path,
|
||||
llm_model=llm_model,
|
||||
script_config=script_config,
|
||||
status="pending",
|
||||
)
|
||||
db.add(project)
|
||||
@@ -501,6 +503,13 @@ def get_audiobook_chapter(db: Session, chapter_id: int) -> Optional[AudiobookCha
|
||||
return db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first()
|
||||
|
||||
|
||||
def get_audiobook_chapter_by_index(db: Session, project_id: int, chapter_index: int) -> Optional[AudiobookChapter]:
|
||||
return db.query(AudiobookChapter).filter(
|
||||
AudiobookChapter.project_id == project_id,
|
||||
AudiobookChapter.chapter_index == chapter_index,
|
||||
).first()
|
||||
|
||||
|
||||
def list_audiobook_chapters(db: Session, project_id: int) -> List[AudiobookChapter]:
|
||||
return db.query(AudiobookChapter).filter(
|
||||
AudiobookChapter.project_id == project_id
|
||||
|
||||
@@ -43,6 +43,7 @@ def init_db():
|
||||
for col_def in [
|
||||
"ALTER TABLE audiobook_segments ADD COLUMN emo_text VARCHAR(20)",
|
||||
"ALTER TABLE audiobook_segments ADD COLUMN emo_alpha REAL",
|
||||
"ALTER TABLE audiobook_projects ADD COLUMN script_config JSON",
|
||||
]:
|
||||
try:
|
||||
conn.execute(__import__("sqlalchemy").text(col_def))
|
||||
|
||||
@@ -131,7 +131,8 @@ class AudiobookProject(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
source_type = Column(String(10), nullable=False)
|
||||
source_type = Column(String(20), nullable=False)
|
||||
script_config = Column(JSON, nullable=True)
|
||||
source_path = Column(String(500), nullable=True)
|
||||
source_text = Column(Text, nullable=True)
|
||||
status = Column(String(20), default="pending", nullable=False, index=True)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
@@ -9,6 +9,26 @@ class AudiobookProjectCreate(BaseModel):
|
||||
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):
|
||||
title: str
|
||||
genre: str
|
||||
subgenre: str = ""
|
||||
premise: str
|
||||
style: str = ""
|
||||
num_characters: int = 5
|
||||
num_chapters: int = 8
|
||||
|
||||
|
||||
class AudiobookProjectResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
@@ -17,6 +37,7 @@ class AudiobookProjectResponse(BaseModel):
|
||||
status: str
|
||||
llm_model: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
script_config: Optional[Dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -52,6 +73,10 @@ class AudiobookProjectDetail(AudiobookProjectResponse):
|
||||
chapters: List[AudiobookChapterResponse] = []
|
||||
|
||||
|
||||
class ContinueScriptRequest(BaseModel):
|
||||
additional_chapters: int = 4
|
||||
|
||||
|
||||
class AudiobookAnalyzeRequest(BaseModel):
|
||||
turbo: bool = False
|
||||
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
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 {
|
||||
title: string
|
||||
genre: string
|
||||
subgenre?: string
|
||||
premise: string
|
||||
style?: string
|
||||
num_characters?: number
|
||||
num_chapters?: number
|
||||
}
|
||||
|
||||
export interface AudiobookProject {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -8,6 +28,7 @@ export interface AudiobookProject {
|
||||
status: string
|
||||
llm_model?: string
|
||||
error_message?: string
|
||||
script_config?: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -58,6 +79,16 @@ export interface LLMConfig {
|
||||
}
|
||||
|
||||
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> => {
|
||||
const response = await apiClient.post<AudiobookProject>('/audiobook/projects/generate-script', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createProject: async (data: {
|
||||
title: string
|
||||
source_type: string
|
||||
@@ -183,6 +214,10 @@ export const audiobookApi = {
|
||||
await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`)
|
||||
},
|
||||
|
||||
continueScript: async (id: number, additionalChapters: number): Promise<void> => {
|
||||
await apiClient.post(`/audiobook/projects/${id}/continue-script`, { additional_chapters: additionalChapters })
|
||||
},
|
||||
|
||||
deleteProject: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/audiobook/projects/${id}`)
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"pending": "Pending",
|
||||
"analyzing": "Analyzing",
|
||||
"characters_ready": "Awaiting Character Review",
|
||||
"parsing": "Parsing Chapters",
|
||||
"parsing": "Extracting Dialogue",
|
||||
"ready": "Ready",
|
||||
"processing": "Processing",
|
||||
"generating": "Generating",
|
||||
@@ -89,6 +89,7 @@
|
||||
|
||||
"confirm": {
|
||||
"button": "Confirm Characters · Identify Chapters",
|
||||
"generateScript": "Confirm Characters & Generate Script",
|
||||
"loading": "Identifying...",
|
||||
"chaptersRecognized": "Chapters identified"
|
||||
},
|
||||
@@ -96,21 +97,32 @@
|
||||
"chapters": {
|
||||
"title": "Chapters ({{count}} total)",
|
||||
"processAll": "⚡ Process All",
|
||||
"parseAll": "Batch Parse",
|
||||
"generateAll": "Batch Generate",
|
||||
"parseAll": "Batch Extract",
|
||||
"parseAllAI": "Batch Rewrite",
|
||||
"generateAll": "Batch Synthesize",
|
||||
"defaultTitle": "Chapter {{index}}",
|
||||
"parse": "Parse Chapter",
|
||||
"parsing": "Parsing",
|
||||
"parseStarted": "Parsing \"{{title}}\" started",
|
||||
"parseStartedDefault": "Chapter parsing started",
|
||||
"reparse": "Re-parse",
|
||||
"generate": "Generate Chapter",
|
||||
"generateStarted": "Chapter {{index}} generation started",
|
||||
"generateAllStarted": "Full book generation started",
|
||||
"parse": "Extract Dialogue",
|
||||
"parseAI": "Rewrite Chapter",
|
||||
"parsing": "Extracting",
|
||||
"parseStarted": "Extracting \"{{title}}\" started",
|
||||
"parseStartedDefault": "Chapter extraction started",
|
||||
"reparse": "Re-extract",
|
||||
"reparseAI": "Rewrite",
|
||||
"generate": "Synthesize",
|
||||
"generateStarted": "Chapter {{index}} synthesis started",
|
||||
"generateAllStarted": "Full book synthesis started",
|
||||
"processAllStarted": "All tasks triggered",
|
||||
"parseAllStarted": "Batch parsing started",
|
||||
"parseAllStarted": "Batch extraction started",
|
||||
"doneBadge": "{{count}} segments done",
|
||||
"segmentProgress": "{{done}}/{{total}} segments"
|
||||
"segmentProgress": "{{done}}/{{total}} segments",
|
||||
"continueScript": "Continue Script",
|
||||
"continueScriptStarted": "Continue script generation started"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "Continue AI Script",
|
||||
"label": "Additional chapters (1-20)",
|
||||
"start": "Start",
|
||||
"starting": "Generating..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"pending": "未分析",
|
||||
"analyzing": "分析中",
|
||||
"characters_ready": "キャラクター確認待ち",
|
||||
"parsing": "章を解析中",
|
||||
"parsing": "対話抽出中",
|
||||
"ready": "生成待ち",
|
||||
"processing": "処理中",
|
||||
"generating": "生成中",
|
||||
@@ -88,6 +88,7 @@
|
||||
|
||||
"confirm": {
|
||||
"button": "キャラクター確認 · 章を識別",
|
||||
"generateScript": "キャラクター確認 · 台本を生成",
|
||||
"loading": "識別中...",
|
||||
"chaptersRecognized": "章を識別しました"
|
||||
},
|
||||
@@ -95,21 +96,32 @@
|
||||
"chapters": {
|
||||
"title": "章一覧(全 {{count}} 章)",
|
||||
"processAll": "⚡ すべて処理",
|
||||
"parseAll": "一括解析",
|
||||
"generateAll": "一括生成",
|
||||
"parseAll": "一括抽出",
|
||||
"parseAllAI": "一括書き直し",
|
||||
"generateAll": "一括合成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "この章を解析",
|
||||
"parsing": "解析中",
|
||||
"parseStarted": "「{{title}}」の解析を開始しました",
|
||||
"parseStartedDefault": "章の解析を開始しました",
|
||||
"reparse": "再解析",
|
||||
"generate": "この章を生成",
|
||||
"generateStarted": "第 {{index}} 章の生成を開始しました",
|
||||
"generateAllStarted": "全冊生成を開始しました",
|
||||
"parse": "対話抽出",
|
||||
"parseAI": "章を書き直し",
|
||||
"parsing": "抽出中",
|
||||
"parseStarted": "「{{title}}」の抽出を開始しました",
|
||||
"parseStartedDefault": "章の抽出を開始しました",
|
||||
"reparse": "再抽出",
|
||||
"reparseAI": "書き直し",
|
||||
"generate": "音声合成",
|
||||
"generateStarted": "第 {{index}} 章の合成を開始しました",
|
||||
"generateAllStarted": "全冊合成を開始しました",
|
||||
"processAllStarted": "すべてのタスクを開始しました",
|
||||
"parseAllStarted": "一括解析を開始しました",
|
||||
"parseAllStarted": "一括抽出を開始しました",
|
||||
"doneBadge": "{{count}} セグメント完了",
|
||||
"segmentProgress": "{{done}}/{{total}} セグメント"
|
||||
"segmentProgress": "{{done}}/{{total}} セグメント",
|
||||
"continueScript": "章を続けて生成",
|
||||
"continueScriptStarted": "続き生成を開始しました"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "AIスクリプトの続き生成",
|
||||
"label": "追加章数(1-20)",
|
||||
"start": "生成開始",
|
||||
"starting": "生成中..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"pending": "분석 대기",
|
||||
"analyzing": "분석 중",
|
||||
"characters_ready": "캐릭터 확인 대기",
|
||||
"parsing": "챕터 파싱 중",
|
||||
"parsing": "대화 추출 중",
|
||||
"ready": "생성 대기",
|
||||
"processing": "처리 중",
|
||||
"generating": "생성 중",
|
||||
@@ -88,6 +88,7 @@
|
||||
|
||||
"confirm": {
|
||||
"button": "캐릭터 확인 · 챕터 식별",
|
||||
"generateScript": "캐릭터 확인 · 대본 생성",
|
||||
"loading": "식별 중...",
|
||||
"chaptersRecognized": "챕터가 식별되었습니다"
|
||||
},
|
||||
@@ -95,21 +96,32 @@
|
||||
"chapters": {
|
||||
"title": "챕터 목록 (총 {{count}}챕터)",
|
||||
"processAll": "⚡ 전체 처리",
|
||||
"parseAll": "일괄 파싱",
|
||||
"generateAll": "일괄 생성",
|
||||
"parseAll": "일괄 추출",
|
||||
"parseAllAI": "일괄 재작성",
|
||||
"generateAll": "일괄 합성",
|
||||
"defaultTitle": "제 {{index}} 장",
|
||||
"parse": "이 챕터 파싱",
|
||||
"parsing": "파싱 중",
|
||||
"parseStarted": "「{{title}}」 파싱이 시작되었습니다",
|
||||
"parseStartedDefault": "챕터 파싱이 시작되었습니다",
|
||||
"reparse": "재파싱",
|
||||
"generate": "이 챕터 생성",
|
||||
"generateStarted": "제 {{index}} 장 생성이 시작되었습니다",
|
||||
"generateAllStarted": "전체 책 생성이 시작되었습니다",
|
||||
"parse": "대화 추출",
|
||||
"parseAI": "챕터 재작성",
|
||||
"parsing": "추출 중",
|
||||
"parseStarted": "「{{title}}」 추출이 시작되었습니다",
|
||||
"parseStartedDefault": "챕터 추출이 시작되었습니다",
|
||||
"reparse": "재추출",
|
||||
"reparseAI": "재작성",
|
||||
"generate": "음성 합성",
|
||||
"generateStarted": "제 {{index}} 장 합성이 시작되었습니다",
|
||||
"generateAllStarted": "전체 책 합성이 시작되었습니다",
|
||||
"processAllStarted": "모든 작업이 시작되었습니다",
|
||||
"parseAllStarted": "일괄 파싱이 시작되었습니다",
|
||||
"parseAllStarted": "일괄 추출이 시작되었습니다",
|
||||
"doneBadge": "{{count}}개 세그먼트 완료",
|
||||
"segmentProgress": "{{done}}/{{total}} 세그먼트"
|
||||
"segmentProgress": "{{done}}/{{total}} 세그먼트",
|
||||
"continueScript": "챕터 계속 생성",
|
||||
"continueScriptStarted": "이어쓰기 생성이 시작되었습니다"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "AI 스크립트 이어쓰기",
|
||||
"label": "추가 챕터 수(1-20)",
|
||||
"start": "생성 시작",
|
||||
"starting": "생성 중..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"pending": "待分析",
|
||||
"analyzing": "分析中",
|
||||
"characters_ready": "角色待确认",
|
||||
"parsing": "解析章节",
|
||||
"parsing": "提取对话",
|
||||
"ready": "待生成",
|
||||
"processing": "处理中",
|
||||
"generating": "生成中",
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
"confirm": {
|
||||
"button": "确认角色 · 识别章节",
|
||||
"generateScript": "确认角色并生成剧本",
|
||||
"loading": "识别中...",
|
||||
"chaptersRecognized": "章节已识别"
|
||||
},
|
||||
@@ -99,21 +100,32 @@
|
||||
"chapters": {
|
||||
"title": "章节列表(共 {{count}} 章)",
|
||||
"processAll": "⚡ 全部处理",
|
||||
"parseAll": "批量解析",
|
||||
"generateAll": "批量生成",
|
||||
"parseAll": "批量提取",
|
||||
"parseAllAI": "批量重写",
|
||||
"generateAll": "批量合成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "解析此章",
|
||||
"parsing": "解析中",
|
||||
"parseStarted": "「{{title}}」解析已开始",
|
||||
"parseStartedDefault": "章节解析已开始",
|
||||
"reparse": "重新解析",
|
||||
"generate": "生成此章",
|
||||
"generateStarted": "第 {{index}} 章生成已开始",
|
||||
"generateAllStarted": "全书生成已开始",
|
||||
"parse": "提取对话",
|
||||
"parseAI": "重写章节",
|
||||
"parsing": "提取中",
|
||||
"parseStarted": "「{{title}}」提取已开始",
|
||||
"parseStartedDefault": "章节提取已开始",
|
||||
"reparse": "重新提取",
|
||||
"reparseAI": "重写",
|
||||
"generate": "合成音频",
|
||||
"generateStarted": "第 {{index}} 章合成已开始",
|
||||
"generateAllStarted": "全书合成已开始",
|
||||
"processAllStarted": "全部任务已触发",
|
||||
"parseAllStarted": "批量解析已开始",
|
||||
"parseAllStarted": "批量提取已开始",
|
||||
"doneBadge": "已完成 {{count}} 段",
|
||||
"segmentProgress": "{{done}}/{{total}} 段"
|
||||
"segmentProgress": "{{done}}/{{total}} 段",
|
||||
"continueScript": "续写章节",
|
||||
"continueScriptStarted": "续写任务已开始"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "续写 AI 剧本章节",
|
||||
"label": "续写章节数(1-20)",
|
||||
"start": "开始续写",
|
||||
"starting": "生成中..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"pending": "待分析",
|
||||
"analyzing": "分析中",
|
||||
"characters_ready": "角色待確認",
|
||||
"parsing": "解析章節",
|
||||
"parsing": "提取對話",
|
||||
"ready": "待生成",
|
||||
"processing": "處理中",
|
||||
"generating": "生成中",
|
||||
@@ -88,6 +88,7 @@
|
||||
|
||||
"confirm": {
|
||||
"button": "確認角色 · 識別章節",
|
||||
"generateScript": "確認角色並生成劇本",
|
||||
"loading": "識別中...",
|
||||
"chaptersRecognized": "章節已識別"
|
||||
},
|
||||
@@ -95,21 +96,32 @@
|
||||
"chapters": {
|
||||
"title": "章節列表(共 {{count}} 章)",
|
||||
"processAll": "⚡ 全部處理",
|
||||
"parseAll": "批量解析",
|
||||
"generateAll": "批量生成",
|
||||
"parseAll": "批量提取",
|
||||
"parseAllAI": "批量重寫",
|
||||
"generateAll": "批量合成",
|
||||
"defaultTitle": "第 {{index}} 章",
|
||||
"parse": "解析此章",
|
||||
"parsing": "解析中",
|
||||
"parseStarted": "「{{title}}」解析已開始",
|
||||
"parseStartedDefault": "章節解析已開始",
|
||||
"reparse": "重新解析",
|
||||
"generate": "生成此章",
|
||||
"generateStarted": "第 {{index}} 章生成已開始",
|
||||
"generateAllStarted": "全書生成已開始",
|
||||
"parse": "提取對話",
|
||||
"parseAI": "重寫章節",
|
||||
"parsing": "提取中",
|
||||
"parseStarted": "「{{title}}」提取已開始",
|
||||
"parseStartedDefault": "章節提取已開始",
|
||||
"reparse": "重新提取",
|
||||
"reparseAI": "重寫",
|
||||
"generate": "合成音訊",
|
||||
"generateStarted": "第 {{index}} 章合成已開始",
|
||||
"generateAllStarted": "全書合成已開始",
|
||||
"processAllStarted": "全部任務已觸發",
|
||||
"parseAllStarted": "批量解析已開始",
|
||||
"parseAllStarted": "批量提取已開始",
|
||||
"doneBadge": "已完成 {{count}} 段",
|
||||
"segmentProgress": "{{done}}/{{total}} 段"
|
||||
"segmentProgress": "{{done}}/{{total}} 段",
|
||||
"continueScript": "續寫章節",
|
||||
"continueScriptStarted": "續寫任務已開始"
|
||||
},
|
||||
"continueScriptDialog": {
|
||||
"title": "續寫 AI 劇本章節",
|
||||
"label": "續寫章節數(1-20)",
|
||||
"start": "開始續寫",
|
||||
"starting": "生成中..."
|
||||
},
|
||||
|
||||
"segments": {
|
||||
|
||||
@@ -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 } from 'lucide-react'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -10,7 +10,8 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Navbar } from '@/components/Navbar'
|
||||
import { AudioPlayer } from '@/components/AudioPlayer'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment } from '@/lib/api/audiobook'
|
||||
import { audiobookApi, type AudiobookProject, type AudiobookProjectDetail, type AudiobookCharacter, type AudiobookSegment, type ScriptGenerationRequest } from '@/lib/api/audiobook'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import apiClient, { formatApiError, adminApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
@@ -352,11 +353,399 @@ function CreateProjectDialog({ open, onClose, onCreated }: { open: boolean; onCl
|
||||
)
|
||||
}
|
||||
|
||||
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 (!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 () => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
onLLM,
|
||||
loading,
|
||||
collapsed,
|
||||
@@ -367,6 +756,7 @@ function ProjectListSidebar({
|
||||
selectedId: number | null
|
||||
onSelect: (id: number) => void
|
||||
onNew: () => void
|
||||
onAIScript: () => void
|
||||
onLLM: () => void
|
||||
loading: boolean
|
||||
collapsed: boolean
|
||||
@@ -395,6 +785,9 @@ function ProjectListSidebar({
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onNew} title={t('newProject')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -698,7 +1091,11 @@ function CharactersPanel({
|
||||
onClick={onConfirm}
|
||||
disabled={loadingAction || editingCharId !== null}
|
||||
>
|
||||
{loadingAction ? t('projectCard.confirm.loading') : t('projectCard.confirm.button')}
|
||||
{loadingAction
|
||||
? t('projectCard.confirm.loading')
|
||||
: project.source_type === 'ai_generated'
|
||||
? t('projectCard.confirm.generateScript', '确认角色并生成剧本')
|
||||
: t('projectCard.confirm.button')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -723,6 +1120,7 @@ function ChaptersPanel({
|
||||
onParseAll,
|
||||
onGenerateAll,
|
||||
onProcessAll,
|
||||
onContinueScript,
|
||||
onDownload,
|
||||
onSequentialPlayingChange,
|
||||
onUpdateSegment,
|
||||
@@ -741,6 +1139,7 @@ function ChaptersPanel({
|
||||
onParseAll: () => void
|
||||
onGenerateAll: () => void
|
||||
onProcessAll: () => void
|
||||
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>
|
||||
@@ -843,6 +1242,7 @@ function ChaptersPanel({
|
||||
}
|
||||
}, [segments, detail])
|
||||
|
||||
const isAIMode = project.source_type === 'ai_generated'
|
||||
const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status)
|
||||
|
||||
return (
|
||||
@@ -855,11 +1255,13 @@ function ChaptersPanel({
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onParseAll}>
|
||||
{t('projectCard.chapters.parseAll')}
|
||||
{isAIMode ? <Bot className="h-3 w-3 mr-1" /> : <Wand2 className="h-3 w-3 mr-1" />}
|
||||
{t(isAIMode ? 'projectCard.chapters.parseAllAI' : 'projectCard.chapters.parseAll')}
|
||||
</Button>
|
||||
)}
|
||||
{detail!.chapters.some(c => c.status === 'ready') && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onGenerateAll}>
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
{t('projectCard.chapters.generateAll')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -868,6 +1270,12 @@ function ChaptersPanel({
|
||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||
</Button>
|
||||
)}
|
||||
{isAIMode && onContinueScript && (
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onContinueScript}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('projectCard.chapters.continueScript')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -904,7 +1312,8 @@ function ChaptersPanel({
|
||||
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
||||
{ch.status === 'pending' && (
|
||||
<Button size="xs" variant="outline" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.parse')}
|
||||
{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' && (
|
||||
@@ -918,10 +1327,12 @@ function ChaptersPanel({
|
||||
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={() => onParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.reparse')}
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} 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>
|
||||
</>
|
||||
)}
|
||||
@@ -933,11 +1344,11 @@ function ChaptersPanel({
|
||||
{(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={() => {
|
||||
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
||||
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
||||
onGenerate(ch.chapter_index, true)
|
||||
}}>
|
||||
<RefreshCw className="h-3 w-3" />{t('projectCard.chapters.generate')}
|
||||
<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="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
||||
<Download className="h-3 w-3" />
|
||||
@@ -946,7 +1357,8 @@ function ChaptersPanel({
|
||||
)}
|
||||
{ch.status === 'error' && (
|
||||
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
|
||||
{t('projectCard.chapters.reparse')}
|
||||
{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>
|
||||
@@ -1100,6 +1512,8 @@ export default function Audiobook() {
|
||||
const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set())
|
||||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showAIScript, setShowAIScript] = useState(false)
|
||||
const [showContinueScript, setShowContinueScript] = useState(false)
|
||||
const [showLLM, setShowLLM] = useState(false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
||||
@@ -1354,6 +1768,24 @@ export default function Audiobook() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1465,8 +1897,9 @@ export default function Audiobook() {
|
||||
setGeneratingChapterIndices(new Set())
|
||||
}
|
||||
}}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false) }}
|
||||
onNew={() => { setShowCreate(v => !v); setShowLLM(false); setShowAIScript(false) }}
|
||||
onAIScript={() => { setShowAIScript(v => !v); setShowCreate(false); setShowLLM(false) }}
|
||||
onLLM={() => { setShowLLM(v => !v); setShowCreate(false); setShowAIScript(false) }}
|
||||
loading={loading}
|
||||
collapsed={!sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(v => !v)}
|
||||
@@ -1477,6 +1910,8 @@ export default function Audiobook() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background rounded-tl-2xl">
|
||||
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||||
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||||
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
||||
<ContinueScriptDialog open={showContinueScript} onClose={() => setShowContinueScript(false)} onConfirm={handleContinueScript} />
|
||||
{!selectedProject ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
@@ -1523,9 +1958,9 @@ export default function Audiobook() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'analyzing' && (
|
||||
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
|
||||
<div className="shrink-0 mx-4 mt-2">
|
||||
<LogStream projectId={selectedProject.id} active={status === 'analyzing'} />
|
||||
<LogStream projectId={selectedProject.id} active={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1615,6 +2050,7 @@ export default function Audiobook() {
|
||||
onParseAll={handleParseAll}
|
||||
onGenerateAll={handleGenerateAll}
|
||||
onProcessAll={handleProcessAll}
|
||||
onContinueScript={selectedProject.source_type === 'ai_generated' ? () => setShowContinueScript(true) : undefined}
|
||||
onDownload={handleDownload}
|
||||
onSequentialPlayingChange={setSequentialPlayingId}
|
||||
onUpdateSegment={handleUpdateSegment}
|
||||
|
||||
Reference in New Issue
Block a user