diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index 8887c13..b121887 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -17,6 +17,7 @@ from schemas.audiobook import ( AudiobookProjectResponse, AudiobookProjectDetail, AudiobookCharacterResponse, + AudiobookChapterResponse, AudiobookCharacterEdit, AudiobookSegmentResponse, AudiobookGenerateRequest, @@ -53,11 +54,17 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail: ) for c in (project.characters or []) ] - from db.models import AudiobookSegment - chapter_indices = db.query(AudiobookSegment.chapter_index).filter( - AudiobookSegment.project_id == project.id - ).distinct().all() - chapter_count = len(chapter_indices) + chapters = [ + AudiobookChapterResponse( + id=ch.id, + project_id=ch.project_id, + chapter_index=ch.chapter_index, + title=ch.title, + status=ch.status, + error_message=ch.error_message, + ) + for ch in (project.chapters or []) + ] return AudiobookProjectDetail( id=project.id, user_id=project.user_id, @@ -69,7 +76,7 @@ def _project_to_detail(project, db: Session) -> AudiobookProjectDetail: created_at=project.created_at, updated_at=project.updated_at, characters=characters, - chapter_count=chapter_count, + chapters=chapters, ) @@ -193,22 +200,67 @@ 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 not current_user.llm_api_key or not current_user.llm_base_url or not current_user.llm_model: - raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") + from core.audiobook_service import identify_chapters + try: + identify_chapters(project_id, db, project) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) - from core.audiobook_service import parse_chapters as _parse + return {"message": "Chapters identified", "project_id": project_id} + + +@router.get("/projects/{project_id}/chapters", response_model=list[AudiobookChapterResponse]) +async def list_chapters( + project_id: int, + 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") + chapters = crud.list_audiobook_chapters(db, project_id) + return [ + AudiobookChapterResponse( + id=ch.id, project_id=ch.project_id, chapter_index=ch.chapter_index, + title=ch.title, status=ch.status, error_message=ch.error_message, + ) + for ch in chapters + ] + + +@router.post("/projects/{project_id}/chapters/{chapter_id}/parse") +async def parse_chapter( + project_id: int, + chapter_id: int, + 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") + + chapter = crud.get_audiobook_chapter(db, chapter_id) + if not chapter or chapter.project_id != project_id: + raise HTTPException(status_code=404, detail="Chapter not found") + if chapter.status == "parsing": + raise HTTPException(status_code=400, detail="Chapter is already being parsed") + + if not current_user.llm_api_key or not current_user.llm_base_url or not current_user.llm_model: + raise HTTPException(status_code=400, detail="LLM config not set") + + from core.audiobook_service import parse_one_chapter from core.database import SessionLocal - async def run_parsing(): + async def run(): async_db = SessionLocal() try: db_user = crud.get_user_by_id(async_db, current_user.id) - await _parse(project_id, db_user, async_db) + await parse_one_chapter(project_id, chapter_id, db_user, async_db) finally: async_db.close() - asyncio.create_task(run_parsing()) - return {"message": "Chapter parsing started", "project_id": project_id} + asyncio.create_task(run()) + return {"message": "Parsing started", "chapter_id": chapter_id} @router.put("/projects/{project_id}/characters/{char_id}", response_model=AudiobookCharacterResponse) diff --git a/qwen3-tts-backend/core/audiobook_service.py b/qwen3-tts-backend/core/audiobook_service.py index a20990f..0ae5121 100644 --- a/qwen3-tts-backend/core/audiobook_service.py +++ b/qwen3-tts-backend/core/audiobook_service.py @@ -80,6 +80,19 @@ def _extract_epub_chapters(file_path: str) -> list[str]: raise RuntimeError("ebooklib not installed. Run: pip install EbookLib") +def _sample_full_text(text: str, n_samples: int = 8, sample_size: int = 3000) -> list[str]: + if len(text) <= 30000: + return [text] + segment_size = len(text) // n_samples + samples = [] + for i in range(n_samples): + start = i * segment_size + boundary = text.find("。", start, start + 200) + actual_start = boundary + 1 if boundary != -1 else start + samples.append(text[actual_start:actual_start + sample_size]) + return samples + + def _chunk_chapter(text: str, max_chars: int = 4000) -> list[str]: if len(text) <= max_chars: return [text] @@ -139,13 +152,22 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None: if not text.strip(): raise ValueError("No text content found in project.") - ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},正在分析角色...\n") + samples = _sample_full_text(text) + n = len(samples) + ps.append_line(project_id, f"\n[LLM] 模型:{user.llm_model},共 {n} 个采样段,正在分析角色...\n") ps.append_line(project_id, "") def on_token(token: str) -> None: ps.append_token(project_id, token) - characters_data = await llm.extract_characters(text, on_token=on_token) + def on_sample(i: int, total: int) -> None: + if i < total - 1: + ps.append_line(project_id, f"\n[LLM] 采样段 {i + 1}/{total} 完成,继续分析...\n") + else: + ps.append_line(project_id, f"\n[LLM] 全部 {total} 个采样段完成,正在合并角色列表...\n") + ps.append_line(project_id, "") + + characters_data = await llm.extract_characters(samples, on_token=on_token, on_sample=on_sample) has_narrator = any(c.get("name") == "narrator" for c in characters_data) if not has_narrator: @@ -196,17 +218,44 @@ async def analyze_project(project_id: int, user: User, db: Session) -> None: crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e)) -async def parse_chapters(project_id: int, user: User, db: Session) -> None: - project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first() - if not project: +def _get_chapter_title(text: str) -> str: + first_line = text.strip().split('\n')[0].strip() + return first_line[:80] if len(first_line) <= 80 else first_line[:77] + '...' + + +def identify_chapters(project_id: int, db, project) -> None: + if project.source_type == "epub" and project.source_path: + texts = _extract_epub_chapters(project.source_path) + else: + texts = _split_into_chapters(project.source_text or "") + + crud.delete_audiobook_chapters(db, project_id) + crud.delete_audiobook_segments(db, project_id) + + real_idx = 0 + for text in texts: + if text.strip(): + crud.create_audiobook_chapter( + db, project_id, real_idx, text.strip(), + title=_get_chapter_title(text), + ) + real_idx += 1 + + crud.update_audiobook_project_status(db, project_id, "ready") + logger.info(f"Project {project_id} chapters identified: {real_idx} chapters") + + +async def parse_one_chapter(project_id: int, chapter_id: int, user: User, db) -> None: + from db.models import AudiobookChapter as ChapterModel + chapter = crud.get_audiobook_chapter(db, chapter_id) + if not chapter: return ps.reset(project_id) try: - crud.update_audiobook_project_status(db, project_id, "parsing") + crud.update_audiobook_chapter_status(db, chapter_id, "parsing") llm = _get_llm_service(user) - characters = crud.list_audiobook_characters(db, project_id) if not characters: raise ValueError("No characters found. Please analyze the project first.") @@ -214,86 +263,63 @@ async def parse_chapters(project_id: int, user: User, db: Session) -> None: char_map: dict[str, AudiobookCharacter] = {c.name: c for c in characters} character_names = list(char_map.keys()) - text = project.source_text or "" - if not text.strip(): - raise ValueError("No text content found in project.") + label = chapter.title or f"第 {chapter.chapter_index + 1} 章" + ps.append_line(project_id, f"[{label}] 开始解析 ({len(chapter.source_text)} 字)") - if project.source_type == "epub" and project.source_path: - chapters = _extract_epub_chapters(project.source_path) - else: - chapters = _split_into_chapters(text) + crud.delete_audiobook_segments_for_chapter(db, project_id, chapter.chapter_index) - non_empty = [(i, t) for i, t in enumerate(chapters) if t.strip()] - ps.append_line(project_id, f"[解析] 共 {len(non_empty)} 章,角色:{', '.join(character_names)}\n") + chunks = _chunk_chapter(chapter.source_text, max_chars=4000) + ps.append_line(project_id, f"共 {len(chunks)} 块\n") - crud.delete_audiobook_segments(db, project_id) + seg_counter = 0 + for i, chunk in enumerate(chunks): + ps.append_line(project_id, f"块 {i + 1}/{len(chunks)} → ") + ps.append_line(project_id, "") - seg_counters: dict[int, int] = {} - for chapter_idx, chapter_text in non_empty: - chunks = _chunk_chapter(chapter_text, max_chars=4000) - logger.info(f"Chapter {chapter_idx}: {len(chapter_text)} chars → {len(chunks)} chunk(s)") - ps.append_line(project_id, f"[第 {chapter_idx + 1} 章] {len(chapter_text)} 字,{len(chunks)} 块") + def on_token(token: str) -> None: + ps.append_token(project_id, token) - for chunk_i, chunk in enumerate(chunks): - ps.append_line(project_id, f" 块 {chunk_i + 1}/{len(chunks)} → ") - ps.append_line(project_id, "") - - def on_token(token: str) -> None: - ps.append_token(project_id, token) - - try: - segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token) - except Exception as e: - logger.warning(f"Chapter {chapter_idx} chunk LLM parse failed, fallback to narrator: {e}") - ps.append_line(project_id, f"\n [回退] LLM 失败,整块归属 narrator") - narrator = char_map.get("narrator") - if narrator: - idx = seg_counters.get(chapter_idx, 0) - crud.create_audiobook_segment( - db=db, - project_id=project_id, - character_id=narrator.id, - text=chunk.strip(), - chapter_index=chapter_idx, - segment_index=idx, - ) - seg_counters[chapter_idx] = idx + 1 - continue - - chunk_seg_count = 0 - for seg in segments_data: - char_name = seg.get("character", "narrator") - seg_text = seg.get("text", "").strip() - if not seg_text: - continue - char = char_map.get(char_name) or char_map.get("narrator") - if char is None: - continue - idx = seg_counters.get(chapter_idx, 0) + try: + segments_data = await llm.parse_chapter_segments(chunk, character_names, on_token=on_token) + except Exception as e: + logger.warning(f"Chapter {chapter_id} chunk {i} failed: {e}") + ps.append_line(project_id, f"\n[回退] {e}") + narrator = char_map.get("narrator") + if narrator: crud.create_audiobook_segment( - db=db, - project_id=project_id, - character_id=char.id, - text=seg_text, - chapter_index=chapter_idx, - segment_index=idx, + db, project_id, narrator.id, chunk.strip(), + chapter.chapter_index, seg_counter, ) - seg_counters[chapter_idx] = idx + 1 - chunk_seg_count += 1 + seg_counter += 1 + continue - ps.append_line(project_id, f"\n [完成] 解析出 {chunk_seg_count} 段") + chunk_count = 0 + for seg in segments_data: + seg_text = seg.get("text", "").strip() + if not seg_text: + continue + char = char_map.get(seg.get("character", "narrator")) or char_map.get("narrator") + if not char: + continue + crud.create_audiobook_segment( + db, project_id, char.id, seg_text, + chapter.chapter_index, seg_counter, + ) + seg_counter += 1 + chunk_count += 1 - total_segs = sum(seg_counters.values()) - ps.append_line(project_id, f"\n[完成] 全部解析完毕,共 {total_segs} 段") - crud.update_audiobook_project_status(db, project_id, "ready") + ps.append_line(project_id, f"\n✓ {chunk_count} 段") + + ps.append_line(project_id, f"\n[完成] 共 {seg_counter} 段") + crud.update_audiobook_chapter_status(db, chapter_id, "ready") ps.mark_done(project_id) - logger.info(f"Project {project_id} chapter parsing complete: {len(chapters)} chapters") + logger.info(f"Chapter {chapter_id} parsed: {seg_counter} segments") except Exception as e: - logger.error(f"Chapter parsing failed for project {project_id}: {e}", exc_info=True) + logger.error(f"parse_one_chapter {chapter_id} failed: {e}", exc_info=True) ps.append_line(project_id, f"\n[错误] {e}") ps.mark_done(project_id) - crud.update_audiobook_project_status(db, project_id, "error", error_message=str(e)) + crud.update_audiobook_chapter_status(db, chapter_id, "error", error_message=str(e)) async def _bootstrap_character_voices(segments, user, backend, backend_type: str, db: Session) -> None: diff --git a/qwen3-tts-backend/core/llm_service.py b/qwen3-tts-backend/core/llm_service.py index 63194a4..b5508c2 100644 --- a/qwen3-tts-backend/core/llm_service.py +++ b/qwen3-tts-backend/core/llm_service.py @@ -115,7 +115,7 @@ class LLMService: logger.error(f"JSON parse failed. Raw response (first 500 chars): {raw[:500]}") raise - async def extract_characters(self, text: str, on_token=None) -> list[Dict]: + async def extract_characters(self, text_samples: list[str], on_token=None, on_sample=None) -> list[Dict]: system_prompt = ( "你是一个专业的小说分析助手兼声音导演。请分析给定的小说文本,提取所有出现的角色(包括旁白narrator)。\n" "对每个角色,instruct字段必须是详细的声音导演说明,需覆盖以下六个维度,每个维度单独一句,用换行分隔:\n" @@ -128,9 +128,44 @@ class LLMService: "只输出JSON,格式如下,不要有其他文字:\n" '{"characters": [{"name": "narrator", "description": "第三人称叙述者", "instruct": "音色信息:...\\n身份背景:...\\n年龄设定:...\\n外貌特征:...\\n性格特质:...\\n叙事风格:..."}, ...]}' ) - user_message = f"请分析以下小说文本并提取角色:\n\n{text[:30000]}" - result = await self.stream_chat_json(system_prompt, user_message, on_token) - return result.get("characters", []) + raw_all: list[Dict] = [] + for i, sample in enumerate(text_samples): + logger.info(f"Extracting characters from sample {i+1}/{len(text_samples)}") + user_message = f"请分析以下小说文本并提取角色:\n\n{sample}" + try: + result = await self.stream_chat_json(system_prompt, user_message, on_token) + raw_all.extend(result.get("characters", [])) + except Exception as e: + logger.warning(f"Character extraction failed for sample {i+1}: {e}") + if on_sample: + on_sample(i, len(text_samples)) + if len(text_samples) == 1: + return raw_all + return await self.merge_characters(raw_all) + + async def merge_characters(self, raw_characters: list[Dict]) -> list[Dict]: + system_prompt = ( + "你是一个专业的小说角色整合助手。你收到的是从同一本书不同段落中提取的角色列表,其中可能存在重复。\n" + "请完成以下任务:\n" + "1. 识别并合并重复角色:通过名字完全相同或高度相似(全名与简称、不同译写)来判断。\n" + "2. 合并时保留最完整、最详细的 description 和 instruct 字段。\n" + "3. narrator 角色只保留一个。\n" + "4. 去除无意义的占位角色(name 为空或仅含标点)。\n" + "只输出 JSON,不要有其他文字:\n" + '{"characters": [{"name": "...", "description": "...", "instruct": "..."}, ...]}' + ) + user_message = f"请整合以下角色列表:\n\n{json.dumps(raw_characters, ensure_ascii=False, indent=2)}" + try: + result = await self.chat_json(system_prompt, user_message) + return result.get("characters", []) + except Exception as e: + logger.warning(f"Character merge failed, falling back to name-dedup: {e}") + seen: dict[str, Dict] = {} + for c in raw_characters: + name = c.get("name", "") + if name and name not in seen: + seen[name] = c + return list(seen.values()) async def parse_chapter_segments(self, chapter_text: str, character_names: list[str], on_token=None) -> list[Dict]: names_str = "、".join(character_names) diff --git a/qwen3-tts-backend/db/crud.py b/qwen3-tts-backend/db/crud.py index e0f37c5..b81435a 100644 --- a/qwen3-tts-backend/db/crud.py +++ b/qwen3-tts-backend/db/crud.py @@ -3,7 +3,7 @@ from typing import Optional, List, Dict, Any from datetime import datetime from sqlalchemy.orm import Session -from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookCharacter, AudiobookSegment +from db.models import User, Job, VoiceCache, SystemSettings, VoiceDesign, AudiobookProject, AudiobookChapter, AudiobookCharacter, AudiobookSegment def get_user_by_username(db: Session, username: str) -> Optional[User]: return db.query(User).filter(User.username == username).first() @@ -449,6 +449,66 @@ def delete_audiobook_project(db: Session, project_id: int, user_id: int) -> bool return True +def create_audiobook_chapter( + db: Session, + project_id: int, + chapter_index: int, + source_text: str, + title: Optional[str] = None, +) -> AudiobookChapter: + chapter = AudiobookChapter( + project_id=project_id, + chapter_index=chapter_index, + source_text=source_text, + title=title, + status="pending", + ) + db.add(chapter) + db.commit() + db.refresh(chapter) + return chapter + + +def get_audiobook_chapter(db: Session, chapter_id: int) -> Optional[AudiobookChapter]: + return db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first() + + +def list_audiobook_chapters(db: Session, project_id: int) -> List[AudiobookChapter]: + return db.query(AudiobookChapter).filter( + AudiobookChapter.project_id == project_id + ).order_by(AudiobookChapter.chapter_index).all() + + +def update_audiobook_chapter_status( + db: Session, + chapter_id: int, + status: str, + error_message: Optional[str] = None, +) -> Optional[AudiobookChapter]: + chapter = db.query(AudiobookChapter).filter(AudiobookChapter.id == chapter_id).first() + if not chapter: + return None + chapter.status = status + if error_message is not None: + chapter.error_message = error_message + db.commit() + db.refresh(chapter) + return chapter + + +def delete_audiobook_chapters(db: Session, project_id: int) -> None: + db.query(AudiobookChapter).filter(AudiobookChapter.project_id == project_id).delete() + db.commit() + + +def delete_audiobook_segments_for_chapter(db: Session, project_id: int, chapter_index: int) -> None: + db.query(AudiobookSegment).filter( + AudiobookSegment.project_id == project_id, + AudiobookSegment.chapter_index == chapter_index, + ).delete() + db.commit() + + def create_audiobook_character( db: Session, project_id: int, diff --git a/qwen3-tts-backend/db/models.py b/qwen3-tts-backend/db/models.py index 8c0ebc0..d3f1175 100644 --- a/qwen3-tts-backend/db/models.py +++ b/qwen3-tts-backend/db/models.py @@ -141,9 +141,28 @@ class AudiobookProject(Base): user = relationship("User", back_populates="audiobook_projects") characters = relationship("AudiobookCharacter", back_populates="project", cascade="all, delete-orphan") + chapters = relationship("AudiobookChapter", back_populates="project", cascade="all, delete-orphan", order_by="AudiobookChapter.chapter_index") segments = relationship("AudiobookSegment", back_populates="project", cascade="all, delete-orphan") +class AudiobookChapter(Base): + __tablename__ = "audiobook_chapters" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("audiobook_projects.id"), nullable=False, index=True) + chapter_index = Column(Integer, nullable=False) + title = Column(String(500), nullable=True) + source_text = Column(Text, nullable=False) + status = Column(String(20), default="pending", nullable=False) + error_message = Column(Text, nullable=True) + + project = relationship("AudiobookProject", back_populates="chapters") + + __table_args__ = ( + Index('idx_chapter_project_idx', 'project_id', 'chapter_index'), + ) + + class AudiobookCharacter(Base): __tablename__ = "audiobook_characters" diff --git a/qwen3-tts-backend/schemas/audiobook.py b/qwen3-tts-backend/schemas/audiobook.py index a33e430..15841bf 100644 --- a/qwen3-tts-backend/schemas/audiobook.py +++ b/qwen3-tts-backend/schemas/audiobook.py @@ -34,9 +34,20 @@ class AudiobookCharacterResponse(BaseModel): model_config = ConfigDict(from_attributes=True) +class AudiobookChapterResponse(BaseModel): + id: int + project_id: int + chapter_index: int + title: Optional[str] = None + status: str + error_message: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + class AudiobookProjectDetail(AudiobookProjectResponse): characters: List[AudiobookCharacterResponse] = [] - chapter_count: int = 0 + chapters: List[AudiobookChapterResponse] = [] class AudiobookGenerateRequest(BaseModel): diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index f61f405..a779f0b 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -21,9 +21,18 @@ export interface AudiobookCharacter { voice_design_id?: number } +export interface AudiobookChapter { + id: number + project_id: number + chapter_index: number + title?: string + status: string + error_message?: string +} + export interface AudiobookProjectDetail extends AudiobookProject { characters: AudiobookCharacter[] - chapter_count: number + chapters: AudiobookChapter[] } export interface AudiobookSegment { @@ -96,6 +105,15 @@ export const audiobookApi = { await apiClient.post(`/audiobook/projects/${id}/confirm`) }, + listChapters: async (id: number): Promise => { + const response = await apiClient.get(`/audiobook/projects/${id}/chapters`) + return response.data + }, + + parseChapter: async (projectId: number, chapterId: number): Promise => { + await apiClient.post(`/audiobook/projects/${projectId}/chapters/${chapterId}/parse`) + }, + generate: async (id: number, chapterIndex?: number): Promise => { await apiClient.post(`/audiobook/projects/${id}/generate`, { chapter_index: chapterIndex ?? null, diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index 13be56b..39cbd9e 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -36,10 +36,9 @@ const STATUS_COLORS: Record = { const STEP_HINTS: Record = { pending: '第 1 步:点击「分析」,LLM 将自动提取角色列表', analyzing: '第 1 步:LLM 正在提取角色,请稍候...', - characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 解析章节」', - parsing: '第 3 步:LLM 正在解析章节脚本,请稍候...', - ready: '第 4 步:按章节逐章生成音频,或一次性生成全书', - generating: '第 5 步:正在合成音频,已完成片段可立即播放', + characters_ready: '第 2 步:确认角色信息,可编辑后点击「确认角色 · 识别章节」', + ready: '第 3 步:逐章解析剧本(LLM),解析完的章节可立即生成音频', + generating: '第 4 步:正在合成音频,已完成片段可立即播放', } function SequentialPlayer({ @@ -353,18 +352,21 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr prevStatusRef.current = project.status }, [project.status, project.title]) - useEffect(() => { - if (!isPolling) return - if (['analyzing', 'parsing', 'generating'].includes(project.status)) return - if (!segments.some(s => s.status === 'generating')) setIsPolling(false) - }, [isPolling, project.status, segments]) + const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false useEffect(() => { - const shouldPoll = isPolling || ['analyzing', 'parsing', 'generating'].includes(project.status) + if (!isPolling) return + if (['analyzing', 'generating'].includes(project.status)) return + if (hasParsingChapter) return + if (!segments.some(s => s.status === 'generating')) setIsPolling(false) + }, [isPolling, project.status, segments, hasParsingChapter]) + + useEffect(() => { + const shouldPoll = isPolling || ['analyzing', 'generating'].includes(project.status) || hasParsingChapter if (!shouldPoll) return - const id = setInterval(() => { onRefresh(); fetchSegments() }, 1500) + const id = setInterval(() => { onRefresh(); fetchSegments(); fetchDetail() }, 1500) return () => clearInterval(id) - }, [isPolling, project.status, onRefresh, fetchSegments]) + }, [isPolling, project.status, hasParsingChapter, onRefresh, fetchSegments, fetchDetail]) const handleAnalyze = async () => { const s = project.status @@ -389,19 +391,28 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr const handleConfirm = async () => { setLoadingAction(true) - setIsPolling(true) try { await audiobookApi.confirmCharacters(project.id) - toast.success('章节解析已开始') + toast.success('章节已识别') onRefresh() + fetchDetail() } catch (e: any) { - setIsPolling(false) toast.error(formatApiError(e)) } finally { setLoadingAction(false) } } + const handleParseChapter = async (chapterId: number, title?: string) => { + try { + await audiobookApi.parseChapter(project.id, chapterId) + toast.success(title ? `「${title}」解析已开始` : '章节解析已开始') + fetchDetail() + } catch (e: any) { + toast.error(formatApiError(e)) + } + } + const handleGenerate = async (chapterIndex?: number) => { setLoadingAction(true) setIsPolling(true) @@ -472,19 +483,11 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr } const status = project.status - const isActive = ['analyzing', 'parsing', 'generating'].includes(status) + const isActive = ['analyzing', 'generating'].includes(status) const doneCount = segments.filter(s => s.status === 'done').length const totalCount = segments.length const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0 - const chapterMap = new Map() - segments.forEach(s => { - const arr = chapterMap.get(s.chapter_index) ?? [] - arr.push(s) - chapterMap.set(s.chapter_index, arr) - }) - const chapters = Array.from(chapterMap.entries()).sort(([a], [b]) => a - b) - return (
@@ -531,8 +534,8 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr
)} - {['analyzing', 'parsing'].includes(status) && ( - + {status === 'analyzing' && ( + )} {project.error_message && ( @@ -611,57 +614,70 @@ function ProjectCard({ project, onRefresh }: { project: AudiobookProject; onRefr onClick={handleConfirm} disabled={loadingAction || editingCharId !== null} > - {loadingAction ? '解析中...' : '确认角色 · 解析章节'} + {loadingAction ? '识别中...' : '确认角色 · 识别章节'} )}
)} - {status === 'ready' && chapters.length > 0 && ( + {detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) && (
- 按章节生成(共 {chapters.length} 章) + 章节列表(共 {detail.chapters.length} 章)
-
- {chapters.map(([chIdx, chSegs]) => { +
+ {detail.chapters.map(ch => { + const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index) const chDone = chSegs.filter(s => s.status === 'done').length const chTotal = chSegs.length const chGenerating = chSegs.some(s => s.status === 'generating') - const chAllDone = chDone === chTotal && chTotal > 0 + const chAllDone = chTotal > 0 && chDone === chTotal + const chTitle = ch.title || `第 ${ch.chapter_index + 1} 章` return ( -
- 第 {chIdx + 1} 章 - {chDone}/{chTotal} 段 -
- {chGenerating ? ( -
- - 生成中 -
- ) : chAllDone ? ( - <> - 已完成 - - - ) : ( - - )} + )} + {ch.status === 'parsing' && ( +
+ + 解析中 +
+ )} + {ch.status === 'ready' && !chGenerating && !chAllDone && ( + + )} + {ch.status === 'ready' && chGenerating && ( +
+ + {chDone}/{chTotal} 段 +
+ )} + {ch.status === 'ready' && chAllDone && ( + <> + 已完成 {chDone} 段 + + + )} + {ch.status === 'error' && ( + + )} +
+ {ch.status === 'parsing' && ( + + )}
) })}