From 7644584c391612d0800a0dc3db2d29fa2939429f Mon Sep 17 00:00:00 2001 From: bdim404 Date: Fri, 13 Mar 2026 11:59:37 +0800 Subject: [PATCH] feat: add continue script functionality for AI-generated audiobook projects --- qwen3-tts-backend/api/audiobook.py | 38 +++++ qwen3-tts-backend/core/audiobook_service.py | 156 ++++++++++++++++++ qwen3-tts-backend/core/llm_service.py | 36 ++++ qwen3-tts-backend/schemas/audiobook.py | 4 + qwen3-tts-frontend/src/lib/api/audiobook.ts | 4 + .../src/locales/en-US/audiobook.json | 10 +- .../src/locales/ja-JP/audiobook.json | 10 +- .../src/locales/ko-KR/audiobook.json | 10 +- .../src/locales/zh-CN/audiobook.json | 10 +- .../src/locales/zh-TW/audiobook.json | 10 +- qwen3-tts-frontend/src/pages/Audiobook.tsx | 74 ++++++++- 11 files changed, 355 insertions(+), 7 deletions(-) diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index 74466a3..d48eca0 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -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, diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index 2bdda2c..2b68284 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -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: diff --git a/qwen3-tts-backend/core/llm_service.py b/qwen3-tts-backend/core/llm_service.py index 281ee2f..f694f21 100644 --- a/qwen3-tts-backend/core/llm_service.py +++ b/qwen3-tts-backend/core/llm_service.py @@ -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 = ( diff --git a/qwen3-tts-backend/schemas/audiobook.py b/qwen3-tts-backend/schemas/audiobook.py index e985ef7..221a03c 100644 --- a/qwen3-tts-backend/schemas/audiobook.py +++ b/qwen3-tts-backend/schemas/audiobook.py @@ -73,6 +73,10 @@ class AudiobookProjectDetail(AudiobookProjectResponse): chapters: List[AudiobookChapterResponse] = [] +class ContinueScriptRequest(BaseModel): + additional_chapters: int = 4 + + class AudiobookAnalyzeRequest(BaseModel): turbo: bool = False diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index c52cb44..2526103 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -214,6 +214,10 @@ export const audiobookApi = { await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`) }, + continueScript: async (id: number, additionalChapters: number): Promise => { + await apiClient.post(`/audiobook/projects/${id}/continue-script`, { additional_chapters: additionalChapters }) + }, + deleteProject: async (id: number): Promise => { await apiClient.delete(`/audiobook/projects/${id}`) }, diff --git a/qwen3-tts-frontend/src/locales/en-US/audiobook.json b/qwen3-tts-frontend/src/locales/en-US/audiobook.json index 3bb1056..67ad904 100644 --- a/qwen3-tts-frontend/src/locales/en-US/audiobook.json +++ b/qwen3-tts-frontend/src/locales/en-US/audiobook.json @@ -114,7 +114,15 @@ "processAllStarted": "All tasks triggered", "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": { diff --git a/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json b/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json index 16367d6..b5d7fa7 100644 --- a/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json +++ b/qwen3-tts-frontend/src/locales/ja-JP/audiobook.json @@ -113,7 +113,15 @@ "processAllStarted": "すべてのタスクを開始しました", "parseAllStarted": "一括抽出を開始しました", "doneBadge": "{{count}} セグメント完了", - "segmentProgress": "{{done}}/{{total}} セグメント" + "segmentProgress": "{{done}}/{{total}} セグメント", + "continueScript": "章を続けて生成", + "continueScriptStarted": "続き生成を開始しました" + }, + "continueScriptDialog": { + "title": "AIスクリプトの続き生成", + "label": "追加章数(1-20)", + "start": "生成開始", + "starting": "生成中..." }, "segments": { diff --git a/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json b/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json index 3566baf..4a374cc 100644 --- a/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json +++ b/qwen3-tts-frontend/src/locales/ko-KR/audiobook.json @@ -113,7 +113,15 @@ "processAllStarted": "모든 작업이 시작되었습니다", "parseAllStarted": "일괄 추출이 시작되었습니다", "doneBadge": "{{count}}개 세그먼트 완료", - "segmentProgress": "{{done}}/{{total}} 세그먼트" + "segmentProgress": "{{done}}/{{total}} 세그먼트", + "continueScript": "챕터 계속 생성", + "continueScriptStarted": "이어쓰기 생성이 시작되었습니다" + }, + "continueScriptDialog": { + "title": "AI 스크립트 이어쓰기", + "label": "추가 챕터 수(1-20)", + "start": "생성 시작", + "starting": "생성 중..." }, "segments": { diff --git a/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json b/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json index cc2a4a1..fe46df7 100644 --- a/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json +++ b/qwen3-tts-frontend/src/locales/zh-CN/audiobook.json @@ -117,7 +117,15 @@ "processAllStarted": "全部任务已触发", "parseAllStarted": "批量提取已开始", "doneBadge": "已完成 {{count}} 段", - "segmentProgress": "{{done}}/{{total}} 段" + "segmentProgress": "{{done}}/{{total}} 段", + "continueScript": "续写章节", + "continueScriptStarted": "续写任务已开始" + }, + "continueScriptDialog": { + "title": "续写 AI 剧本章节", + "label": "续写章节数(1-20)", + "start": "开始续写", + "starting": "生成中..." }, "segments": { diff --git a/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json b/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json index 9760105..a13f9ae 100644 --- a/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json +++ b/qwen3-tts-frontend/src/locales/zh-TW/audiobook.json @@ -113,7 +113,15 @@ "processAllStarted": "全部任務已觸發", "parseAllStarted": "批量提取已開始", "doneBadge": "已完成 {{count}} 段", - "segmentProgress": "{{done}}/{{total}} 段" + "segmentProgress": "{{done}}/{{total}} 段", + "continueScript": "續寫章節", + "continueScriptStarted": "續寫任務已開始" + }, + "continueScriptDialog": { + "title": "續寫 AI 劇本章節", + "label": "續寫章節數(1-20)", + "start": "開始續寫", + "starting": "生成中..." }, "segments": { diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index db18075..bcc60a3 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -699,6 +699,47 @@ function AIScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose: ) } +function ContinueScriptDialog({ open, onClose, onConfirm }: { open: boolean; onClose: () => void; onConfirm: (n: number) => Promise }) { + 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 ( + { if (!v) onClose() }}> + + + {t('projectCard.continueScriptDialog.title')} + +
+ +
+
+ + +
+
+
+ ) +} + function ProjectListSidebar({ projects, selectedId, @@ -1079,6 +1120,7 @@ function ChaptersPanel({ onParseAll, onGenerateAll, onProcessAll, + onContinueScript, onDownload, onSequentialPlayingChange, onUpdateSegment, @@ -1097,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 @@ -1227,6 +1270,12 @@ function ChaptersPanel({ {loadingAction ? : t('projectCard.chapters.processAll')} )} + {isAIMode && onContinueScript && ( + + )} )} @@ -1464,6 +1513,7 @@ export default function Audiobook() { const [sequentialPlayingId, setSequentialPlayingId] = useState(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) @@ -1718,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 { @@ -1843,6 +1911,7 @@ export default function Audiobook() { setShowLLM(false)} /> setShowCreate(false)} onCreated={fetchProjects} /> setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} /> + setShowContinueScript(false)} onConfirm={handleContinueScript} /> {!selectedProject ? ( ) : ( @@ -1889,9 +1958,9 @@ export default function Audiobook() { )} - {status === 'analyzing' && ( + {(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
- +
)} @@ -1981,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}