feat: add continue script functionality for AI-generated audiobook projects
This commit is contained in:
@@ -25,6 +25,7 @@ from schemas.audiobook import (
|
||||
AudiobookAnalyzeRequest,
|
||||
ScriptGenerationRequest,
|
||||
SynopsisGenerationRequest,
|
||||
ContinueScriptRequest,
|
||||
)
|
||||
from core.config import settings
|
||||
|
||||
@@ -230,6 +231,43 @@ async def create_ai_script_project(
|
||||
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,
|
||||
|
||||
@@ -499,6 +499,162 @@ async def generate_ai_script_chapters(project_id: int, user: User, db: Session)
|
||||
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:
|
||||
|
||||
@@ -323,6 +323,42 @@ class LLMService:
|
||||
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 = (
|
||||
|
||||
@@ -73,6 +73,10 @@ class AudiobookProjectDetail(AudiobookProjectResponse):
|
||||
chapters: List[AudiobookChapterResponse] = []
|
||||
|
||||
|
||||
class ContinueScriptRequest(BaseModel):
|
||||
additional_chapters: int = 4
|
||||
|
||||
|
||||
class AudiobookAnalyzeRequest(BaseModel):
|
||||
turbo: bool = False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user