feat: add continue script functionality for AI-generated audiobook projects
This commit is contained in:
@@ -25,6 +25,7 @@ from schemas.audiobook import (
|
|||||||
AudiobookAnalyzeRequest,
|
AudiobookAnalyzeRequest,
|
||||||
ScriptGenerationRequest,
|
ScriptGenerationRequest,
|
||||||
SynopsisGenerationRequest,
|
SynopsisGenerationRequest,
|
||||||
|
ContinueScriptRequest,
|
||||||
)
|
)
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
|
|
||||||
@@ -230,6 +231,43 @@ async def create_ai_script_project(
|
|||||||
return _project_to_response(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)
|
@router.get("/projects/{project_id}", response_model=AudiobookProjectDetail)
|
||||||
async def get_project(
|
async def get_project(
|
||||||
project_id: int,
|
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))
|
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:
|
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()
|
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
|
|||||||
@@ -323,6 +323,42 @@ class LLMService:
|
|||||||
system_prompt, user_message, on_token=on_token, max_tokens=4096, usage_callback=usage_callback
|
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]:
|
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)
|
names_str = "、".join(character_names)
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ class AudiobookProjectDetail(AudiobookProjectResponse):
|
|||||||
chapters: List[AudiobookChapterResponse] = []
|
chapters: List[AudiobookChapterResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ContinueScriptRequest(BaseModel):
|
||||||
|
additional_chapters: int = 4
|
||||||
|
|
||||||
|
|
||||||
class AudiobookAnalyzeRequest(BaseModel):
|
class AudiobookAnalyzeRequest(BaseModel):
|
||||||
turbo: bool = False
|
turbo: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ export const audiobookApi = {
|
|||||||
await apiClient.post(`/audiobook/projects/${projectId}/cancel-batch`)
|
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> => {
|
deleteProject: async (id: number): Promise<void> => {
|
||||||
await apiClient.delete(`/audiobook/projects/${id}`)
|
await apiClient.delete(`/audiobook/projects/${id}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,15 @@
|
|||||||
"processAllStarted": "All tasks triggered",
|
"processAllStarted": "All tasks triggered",
|
||||||
"parseAllStarted": "Batch extraction started",
|
"parseAllStarted": "Batch extraction started",
|
||||||
"doneBadge": "{{count}} segments done",
|
"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": {
|
"segments": {
|
||||||
|
|||||||
@@ -113,7 +113,15 @@
|
|||||||
"processAllStarted": "すべてのタスクを開始しました",
|
"processAllStarted": "すべてのタスクを開始しました",
|
||||||
"parseAllStarted": "一括抽出を開始しました",
|
"parseAllStarted": "一括抽出を開始しました",
|
||||||
"doneBadge": "{{count}} セグメント完了",
|
"doneBadge": "{{count}} セグメント完了",
|
||||||
"segmentProgress": "{{done}}/{{total}} セグメント"
|
"segmentProgress": "{{done}}/{{total}} セグメント",
|
||||||
|
"continueScript": "章を続けて生成",
|
||||||
|
"continueScriptStarted": "続き生成を開始しました"
|
||||||
|
},
|
||||||
|
"continueScriptDialog": {
|
||||||
|
"title": "AIスクリプトの続き生成",
|
||||||
|
"label": "追加章数(1-20)",
|
||||||
|
"start": "生成開始",
|
||||||
|
"starting": "生成中..."
|
||||||
},
|
},
|
||||||
|
|
||||||
"segments": {
|
"segments": {
|
||||||
|
|||||||
@@ -113,7 +113,15 @@
|
|||||||
"processAllStarted": "모든 작업이 시작되었습니다",
|
"processAllStarted": "모든 작업이 시작되었습니다",
|
||||||
"parseAllStarted": "일괄 추출이 시작되었습니다",
|
"parseAllStarted": "일괄 추출이 시작되었습니다",
|
||||||
"doneBadge": "{{count}}개 세그먼트 완료",
|
"doneBadge": "{{count}}개 세그먼트 완료",
|
||||||
"segmentProgress": "{{done}}/{{total}} 세그먼트"
|
"segmentProgress": "{{done}}/{{total}} 세그먼트",
|
||||||
|
"continueScript": "챕터 계속 생성",
|
||||||
|
"continueScriptStarted": "이어쓰기 생성이 시작되었습니다"
|
||||||
|
},
|
||||||
|
"continueScriptDialog": {
|
||||||
|
"title": "AI 스크립트 이어쓰기",
|
||||||
|
"label": "추가 챕터 수(1-20)",
|
||||||
|
"start": "생성 시작",
|
||||||
|
"starting": "생성 중..."
|
||||||
},
|
},
|
||||||
|
|
||||||
"segments": {
|
"segments": {
|
||||||
|
|||||||
@@ -117,7 +117,15 @@
|
|||||||
"processAllStarted": "全部任务已触发",
|
"processAllStarted": "全部任务已触发",
|
||||||
"parseAllStarted": "批量提取已开始",
|
"parseAllStarted": "批量提取已开始",
|
||||||
"doneBadge": "已完成 {{count}} 段",
|
"doneBadge": "已完成 {{count}} 段",
|
||||||
"segmentProgress": "{{done}}/{{total}} 段"
|
"segmentProgress": "{{done}}/{{total}} 段",
|
||||||
|
"continueScript": "续写章节",
|
||||||
|
"continueScriptStarted": "续写任务已开始"
|
||||||
|
},
|
||||||
|
"continueScriptDialog": {
|
||||||
|
"title": "续写 AI 剧本章节",
|
||||||
|
"label": "续写章节数(1-20)",
|
||||||
|
"start": "开始续写",
|
||||||
|
"starting": "生成中..."
|
||||||
},
|
},
|
||||||
|
|
||||||
"segments": {
|
"segments": {
|
||||||
|
|||||||
@@ -113,7 +113,15 @@
|
|||||||
"processAllStarted": "全部任務已觸發",
|
"processAllStarted": "全部任務已觸發",
|
||||||
"parseAllStarted": "批量提取已開始",
|
"parseAllStarted": "批量提取已開始",
|
||||||
"doneBadge": "已完成 {{count}} 段",
|
"doneBadge": "已完成 {{count}} 段",
|
||||||
"segmentProgress": "{{done}}/{{total}} 段"
|
"segmentProgress": "{{done}}/{{total}} 段",
|
||||||
|
"continueScript": "續寫章節",
|
||||||
|
"continueScriptStarted": "續寫任務已開始"
|
||||||
|
},
|
||||||
|
"continueScriptDialog": {
|
||||||
|
"title": "續寫 AI 劇本章節",
|
||||||
|
"label": "續寫章節數(1-20)",
|
||||||
|
"start": "開始續寫",
|
||||||
|
"starting": "生成中..."
|
||||||
},
|
},
|
||||||
|
|
||||||
"segments": {
|
"segments": {
|
||||||
|
|||||||
@@ -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<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({
|
function ProjectListSidebar({
|
||||||
projects,
|
projects,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -1079,6 +1120,7 @@ function ChaptersPanel({
|
|||||||
onParseAll,
|
onParseAll,
|
||||||
onGenerateAll,
|
onGenerateAll,
|
||||||
onProcessAll,
|
onProcessAll,
|
||||||
|
onContinueScript,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSequentialPlayingChange,
|
onSequentialPlayingChange,
|
||||||
onUpdateSegment,
|
onUpdateSegment,
|
||||||
@@ -1097,6 +1139,7 @@ function ChaptersPanel({
|
|||||||
onParseAll: () => void
|
onParseAll: () => void
|
||||||
onGenerateAll: () => void
|
onGenerateAll: () => void
|
||||||
onProcessAll: () => void
|
onProcessAll: () => void
|
||||||
|
onContinueScript?: () => void
|
||||||
onDownload: (chapterIndex?: number) => void
|
onDownload: (chapterIndex?: number) => void
|
||||||
onSequentialPlayingChange: (id: number | null) => void
|
onSequentialPlayingChange: (id: number | null) => void
|
||||||
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
|
onUpdateSegment: (segmentId: number, data: { text: string; emo_text?: string | null; emo_alpha?: number | null }) => Promise<void>
|
||||||
@@ -1227,6 +1270,12 @@ function ChaptersPanel({
|
|||||||
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
||||||
</Button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1464,6 +1513,7 @@ export default function Audiobook() {
|
|||||||
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
const [sequentialPlayingId, setSequentialPlayingId] = useState<number | null>(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [showAIScript, setShowAIScript] = useState(false)
|
const [showAIScript, setShowAIScript] = useState(false)
|
||||||
|
const [showContinueScript, setShowContinueScript] = useState(false)
|
||||||
const [showLLM, setShowLLM] = useState(false)
|
const [showLLM, setShowLLM] = useState(false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
const [charactersCollapsed, setCharactersCollapsed] = useState(false)
|
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 () => {
|
const handleCancelBatch = async () => {
|
||||||
if (!selectedProject) return
|
if (!selectedProject) return
|
||||||
try {
|
try {
|
||||||
@@ -1843,6 +1911,7 @@ export default function Audiobook() {
|
|||||||
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
<LLMConfigDialog open={showLLM} onClose={() => setShowLLM(false)} />
|
||||||
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
<CreateProjectDialog open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
||||||
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
<AIScriptDialog open={showAIScript} onClose={() => setShowAIScript(false)} onCreated={() => { fetchProjects(); setShowAIScript(false) }} />
|
||||||
|
<ContinueScriptDialog open={showContinueScript} onClose={() => setShowContinueScript(false)} onConfirm={handleContinueScript} />
|
||||||
{!selectedProject ? (
|
{!selectedProject ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
@@ -1889,9 +1958,9 @@ export default function Audiobook() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'analyzing' && (
|
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
|
||||||
<div className="shrink-0 mx-4 mt-2">
|
<div className="shrink-0 mx-4 mt-2">
|
||||||
<LogStream projectId={selectedProject.id} active={status === 'analyzing'} />
|
<LogStream projectId={selectedProject.id} active={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1981,6 +2050,7 @@ export default function Audiobook() {
|
|||||||
onParseAll={handleParseAll}
|
onParseAll={handleParseAll}
|
||||||
onGenerateAll={handleGenerateAll}
|
onGenerateAll={handleGenerateAll}
|
||||||
onProcessAll={handleProcessAll}
|
onProcessAll={handleProcessAll}
|
||||||
|
onContinueScript={selectedProject.source_type === 'ai_generated' ? () => setShowContinueScript(true) : undefined}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSequentialPlayingChange={setSequentialPlayingId}
|
onSequentialPlayingChange={setSequentialPlayingId}
|
||||||
onUpdateSegment={handleUpdateSegment}
|
onUpdateSegment={handleUpdateSegment}
|
||||||
|
|||||||
Reference in New Issue
Block a user