diff --git a/backend/app/services/plot_expansion_service.py b/backend/app/services/plot_expansion_service.py index ca9d84d..2b816dd 100644 --- a/backend/app/services/plot_expansion_service.py +++ b/backend/app/services/plot_expansion_service.py @@ -333,16 +333,43 @@ class PlotExpansionService: """ logger.info(f"根据规划创建 {len(chapter_plans)} 个章节记录") - # 如果没有指定起始章节号,自动计算 + # 如果没有指定起始章节号,根据大纲顺序自动计算 if start_chapter_number is None: - # 查询项目中已有章节的最大序号 - max_number_result = await db.execute( - select(func.max(Chapter.chapter_number)) - .where(Chapter.project_id == project_id) + # 1. 获取当前大纲信息 + outline_result = await db.execute( + select(Outline).where(Outline.id == outline_id) ) - max_number = max_number_result.scalar() - start_chapter_number = (max_number or 0) + 1 - logger.info(f"自动计算起始章节号: {start_chapter_number} (当前最大序号: {max_number})") + current_outline = outline_result.scalar_one_or_none() + + if not current_outline: + raise ValueError(f"大纲 {outline_id} 不存在") + + # 2. 查询所有在当前大纲之前的大纲(按order_index排序) + prev_outlines_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.order_index < current_outline.order_index + ) + .order_by(Outline.order_index) + ) + prev_outlines = prev_outlines_result.scalars().all() + + # 3. 计算前面所有大纲已展开的章节总数 + total_prev_chapters = 0 + for prev_outline in prev_outlines: + count_result = await db.execute( + select(func.count(Chapter.id)) + .where( + Chapter.project_id == project_id, + Chapter.outline_id == prev_outline.id + ) + ) + total_prev_chapters += count_result.scalar() or 0 + + # 4. 起始章节号 = 前面所有大纲的章节数 + 1 + start_chapter_number = total_prev_chapters + 1 + logger.info(f"自动计算起始章节号: {start_chapter_number} (基于大纲order_index={current_outline.order_index}, 前置章节数={total_prev_chapters})") chapters = [] for idx, plan in enumerate(chapter_plans): @@ -376,6 +403,14 @@ class PlotExpansionService: await db.refresh(chapter) logger.info(f"成功创建 {len(chapters)} 个章节记录(已保存展开规划数据)") + + # 重新排序当前大纲之后的所有章节 + await self._renumber_subsequent_chapters( + project_id=project_id, + current_outline_id=outline_id, + db=db + ) + return chapters async def _get_outline_context( @@ -717,6 +752,94 @@ class PlotExpansionService: }] + async def _renumber_subsequent_chapters( + self, + project_id: str, + current_outline_id: str, + db: AsyncSession + ): + """ + 重新计算当前大纲之后所有大纲的章节序号 + + Args: + project_id: 项目ID + current_outline_id: 当前大纲ID + db: 数据库会话 + """ + logger.info(f"开始重新排序大纲 {current_outline_id} 之后的所有章节") + + # 1. 获取当前大纲信息 + current_outline_result = await db.execute( + select(Outline).where(Outline.id == current_outline_id) + ) + current_outline = current_outline_result.scalar_one_or_none() + + if not current_outline: + logger.warning(f"大纲 {current_outline_id} 不存在,跳过重新排序") + return + + # 2. 获取当前大纲及之后的所有大纲(按order_index排序) + subsequent_outlines_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.order_index >= current_outline.order_index + ) + .order_by(Outline.order_index) + ) + subsequent_outlines = subsequent_outlines_result.scalars().all() + + # 3. 计算每个大纲的起始章节号 + current_chapter_number = 1 + + # 先计算前面大纲的章节总数 + prev_outlines_result = await db.execute( + select(Outline) + .where( + Outline.project_id == project_id, + Outline.order_index < current_outline.order_index + ) + .order_by(Outline.order_index) + ) + prev_outlines = prev_outlines_result.scalars().all() + + for prev_outline in prev_outlines: + count_result = await db.execute( + select(func.count(Chapter.id)) + .where( + Chapter.project_id == project_id, + Chapter.outline_id == prev_outline.id + ) + ) + current_chapter_number += count_result.scalar() or 0 + + # 4. 逐个大纲更新章节序号 + updated_count = 0 + for outline in subsequent_outlines: + # 获取该大纲的所有章节(按sub_index排序) + chapters_result = await db.execute( + select(Chapter) + .where( + Chapter.project_id == project_id, + Chapter.outline_id == outline.id + ) + .order_by(Chapter.sub_index) + ) + chapters = chapters_result.scalars().all() + + # 更新每个章节的chapter_number + for chapter in chapters: + if chapter.chapter_number != current_chapter_number: + logger.debug(f"更新章节 {chapter.id}: {chapter.chapter_number} -> {current_chapter_number}") + chapter.chapter_number = current_chapter_number + updated_count += 1 + current_chapter_number += 1 + + # 5. 提交更新 + await db.commit() + logger.info(f"重新排序完成,共更新 {updated_count} 个章节的序号") + + # 工厂函数 def create_plot_expansion_service(ai_service: AIService) -> PlotExpansionService: """创建剧情展开服务实例""" diff --git a/frontend/src/pages/Inspiration.tsx b/frontend/src/pages/Inspiration.tsx index 0623232..88b91a9 100644 --- a/frontend/src/pages/Inspiration.tsx +++ b/frontend/src/pages/Inspiration.tsx @@ -48,6 +48,7 @@ const Inspiration: React.FC = () => { const [projectTitle, setProjectTitle] = useState(''); const [progress, setProgress] = useState(0); const [progressMessage, setProgressMessage] = useState(''); + const [errorDetails, setErrorDetails] = useState(''); // 新增:错误详情 const [generationSteps, setGenerationSteps] = useState<{ worldBuilding: 'pending' | 'processing' | 'completed' | 'error'; characters: 'pending' | 'processing' | 'completed' | 'error'; @@ -58,6 +59,10 @@ const Inspiration: React.FC = () => { outline: 'pending' }); + // 新增:保存生成数据,用于重试 + const [generationData, setGenerationData] = useState(null); + const [worldBuildingResult, setWorldBuildingResult] = useState(null); + // 滚动容器引用 const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); @@ -334,6 +339,8 @@ const Inspiration: React.FC = () => { setProjectTitle(data.title); setProgress(0); setProgressMessage('开始创建项目...'); + setErrorDetails(''); // 清空错误详情 + setGenerationData(data); // 保存数据用于重试 // 步骤1: 生成世界观并创建项目 setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); @@ -357,9 +364,12 @@ const Inspiration: React.FC = () => { }, onResult: (result) => { setProjectId(result.project_id); + setWorldBuildingResult(result); // 保存结果用于重试 setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); }, onError: (error) => { + console.error('世界观生成失败:', error); + setErrorDetails(`世界观生成失败: ${error}`); setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); throw new Error(error); }, @@ -370,11 +380,12 @@ const Inspiration: React.FC = () => { ); if (!worldResult?.project_id) { - throw new Error('项目创建失败'); + throw new Error('项目创建失败:未获取到项目ID'); } const createdProjectId = worldResult.project_id; setProjectId(createdProjectId); + setWorldBuildingResult(worldResult); // 步骤2: 生成角色 setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); @@ -403,6 +414,8 @@ const Inspiration: React.FC = () => { setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); }, onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); setGenerationSteps(prev => ({ ...prev, characters: 'error' })); throw new Error(error); }, @@ -433,6 +446,8 @@ const Inspiration: React.FC = () => { setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); }, onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); setGenerationSteps(prev => ({ ...prev, outline: 'error' })); throw new Error(error); }, @@ -450,13 +465,171 @@ const Inspiration: React.FC = () => { } catch (error) { const apiError = error as ApiError; - message.error('创建项目失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误')); - setCurrentStep('genre'); - setGenerationSteps({ - worldBuilding: 'pending', - characters: 'pending', - outline: 'pending' - }); + const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误'; + console.error('创建项目失败:', errorMsg); + setErrorDetails(errorMsg); + message.error('创建项目失败:' + errorMsg); + // 不重置步骤,保持在generating状态以显示重试按钮 + } finally { + setLoading(false); + } + }; + + // 重试世界观生成 + const retryWorldBuilding = async () => { + if (!generationData) return; + + setLoading(true); + setErrorDetails(''); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); + setProgressMessage('重新生成世界观...'); + + try { + const worldResult = await wizardStreamApi.generateWorldBuildingStream( + { + title: generationData.title, + description: generationData.description, + theme: generationData.theme, + genre: generationData.genre.join('、'), + narrative_perspective: generationData.narrative_perspective, + target_words: 100000, + chapter_count: 5, + character_count: 5, + }, + { + onProgress: (msg, prog) => { + setProgress(Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + setProjectId(result.project_id); + setWorldBuildingResult(result); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); + message.success('世界观生成成功!'); + }, + onError: (error) => { + console.error('世界观生成失败:', error); + setErrorDetails(`世界观生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); + }, + onComplete: () => { + console.log('世界观重新生成完成'); + } + } + ); + + if (worldResult?.project_id) { + setProjectId(worldResult.project_id); + setWorldBuildingResult(worldResult); + } + } catch (error: any) { + console.error('重试世界观生成失败:', error); + setErrorDetails(error.message || '重试失败'); + } finally { + setLoading(false); + } + }; + + // 重试角色生成 + const retryCharacters = async () => { + if (!generationData || !projectId || !worldBuildingResult) { + message.warning('请先完成世界观生成'); + return; + } + + setLoading(true); + setErrorDetails(''); + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('重新生成角色...'); + + try { + await wizardStreamApi.generateCharactersStream( + { + project_id: projectId, + count: 5, + world_context: { + time_period: worldBuildingResult.time_period || '', + location: worldBuildingResult.location || '', + atmosphere: worldBuildingResult.atmosphere || '', + rules: worldBuildingResult.rules || '', + }, + theme: generationData.theme, + genre: generationData.genre.join('、'), + }, + { + onProgress: (msg, prog) => { + setProgress(33 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: (result) => { + console.log(`成功生成${result.characters?.length || 0}个角色`); + setGenerationSteps(prev => ({ ...prev, characters: 'completed' })); + message.success('角色生成成功!'); + }, + onError: (error) => { + console.error('角色生成失败:', error); + setErrorDetails(`角色生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + }, + onComplete: () => { + console.log('角色重新生成完成'); + } + } + ); + } catch (error: any) { + console.error('重试角色生成失败:', error); + setErrorDetails(error.message || '重试失败'); + } finally { + setLoading(false); + } + }; + + // 重试大纲生成 + const retryOutline = async () => { + if (!generationData || !projectId) { + message.warning('请先完成世界观和角色生成'); + return; + } + + setLoading(true); + setErrorDetails(''); + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('重新生成大纲...'); + + try { + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: projectId, + chapter_count: 5, + narrative_perspective: generationData.narrative_perspective, + target_words: 100000, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + setProgress(100); + setProgressMessage('项目创建完成!'); + setCurrentStep('complete'); + message.success('大纲生成成功!项目创建完成!'); + }, + onError: (error) => { + console.error('大纲生成失败:', error); + setErrorDetails(`大纲生成失败: ${error}`); + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + }, + onComplete: () => { + console.log('大纲重新生成完成'); + } + } + ); + } catch (error: any) { + console.error('重试大纲生成失败:', error); + setErrorDetails(error.message || '重试失败'); } finally { setLoading(false); } @@ -630,6 +803,10 @@ const Inspiration: React.FC = () => { return { icon: '○', color: '#d9d9d9' }; }; + const hasError = generationSteps.worldBuilding === 'error' || + generationSteps.characters === 'error' || + generationSteps.outline === 'error'; + return (
@@ -639,7 +816,7 @@ const Inspiration: React.FC = () => { <Card style={{ marginBottom: 24 }}> <Progress percent={progress} - status={progress === 100 ? 'success' : 'active'} + status={hasError ? 'exception' : (progress === 100 ? 'success' : 'active')} strokeColor={{ '0%': '#667eea', '100%': '#764ba2', @@ -647,16 +824,33 @@ const Inspiration: React.FC = () => { style={{ marginBottom: 24 }} /> - <Paragraph style={{ fontSize: 16, marginBottom: 32, color: '#666' }}> + <Paragraph style={{ fontSize: 16, marginBottom: 32, color: hasError ? '#ff4d4f' : '#666' }}> {progressMessage} </Paragraph> + {/* 错误详情显示 */} + {errorDetails && ( + <Card + size="small" + style={{ + marginBottom: 24, + background: '#fff2f0', + borderColor: '#ffccc7', + textAlign: 'left' + }} + > + <Text strong style={{ color: '#ff4d4f' }}>错误详情:</Text> + <br /> + <Text style={{ color: '#666', fontSize: 14 }}>{errorDetails}</Text> + </Card> + )} + <Space direction="vertical" size={16} style={{ width: '100%', maxWidth: 400, margin: '0 auto' }}> {[ - { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, - { key: 'characters', label: '生成角色', step: generationSteps.characters }, - { key: 'outline', label: '生成大纲', step: generationSteps.outline }, - ].map(({ key, label, step }) => { + { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding, retry: retryWorldBuilding }, + { key: 'characters', label: '生成角色', step: generationSteps.characters, retry: retryCharacters }, + { key: 'outline', label: '生成大纲', step: generationSteps.outline, retry: retryOutline }, + ].map(({ key, label, step, retry }) => { const status = getStepStatus(step); return ( <div @@ -666,17 +860,31 @@ const Inspiration: React.FC = () => { alignItems: 'center', justifyContent: 'space-between', padding: '12px 20px', - background: step === 'processing' ? '#f0f5ff' : '#fafafa', + background: step === 'processing' ? '#f0f5ff' : (step === 'error' ? '#fff2f0' : '#fafafa'), borderRadius: 8, - border: `1px solid ${step === 'processing' ? '#d6e4ff' : '#f0f0f0'}`, + border: `1px solid ${step === 'processing' ? '#d6e4ff' : (step === 'error' ? '#ffccc7' : '#f0f0f0')}`, }} > <Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}> {label} </Text> - <span style={{ fontSize: 20, color: status.color }}> - {status.icon} - </span> + <Space> + <span style={{ fontSize: 20, color: status.color }}> + {status.icon} + </span> + {step === 'error' && ( + <Button + type="primary" + size="small" + danger + onClick={retry} + loading={loading} + disabled={loading} + > + 重试 + </Button> + )} + </Space> </div> ); })} @@ -684,8 +892,27 @@ const Inspiration: React.FC = () => { </Card> <Paragraph type="secondary" style={{ color: '#fff', opacity: 0.9 }}> - 请耐心等待,AI正在为您精心创作... + {hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'} </Paragraph> + + {hasError && ( + <Space style={{ marginTop: 16 }}> + <Button + size="large" + onClick={() => { + setCurrentStep('confirm'); + setGenerationSteps({ + worldBuilding: 'pending', + characters: 'pending', + outline: 'pending' + }); + setErrorDetails(''); + }} + > + 返回重新配置 + </Button> + </Space> + )} </div> ); };