feat: add continue script functionality for AI-generated audiobook projects

This commit is contained in:
2026-03-13 11:59:37 +08:00
parent 7129047c3f
commit 7644584c39
11 changed files with 355 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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