Compare commits

...

4 Commits

14 changed files with 1435 additions and 92 deletions

View File

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

View File

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

View File

@@ -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}"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`)
},

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Book, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen } 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}