diff --git a/backend/app/api/inspiration.py b/backend/app/api/inspiration.py new file mode 100644 index 0000000..ffa577f --- /dev/null +++ b/backend/app/api/inspiration.py @@ -0,0 +1,387 @@ +"""灵感模式API - 通过对话引导创建项目""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Dict, Any +import json + +from app.database import get_db +from app.services.ai_service import AIService +from app.api.settings import get_user_ai_service +from app.logger import get_logger + +router = APIRouter(prefix="/inspiration", tags=["灵感模式"]) +logger = get_logger(__name__) + + +# 灵感模式提示词模板 +INSPIRATION_PROMPTS = { + "title": { + "system": """你是一位专业的小说创作顾问。 +用户想写的小说:{description} + +请根据用户的想法,生成6个吸引人的书名建议,要求: +1. 符合用户的故事构思 +2. 富有创意和吸引力 +3. 涵盖不同的风格倾向 + +返回JSON格式: +{{ + "prompt": "根据你的想法,我为你准备了几个书名建议:", + "options": ["书名1", "书名2", "书名3", "书名4", "书名5", "书名6"] +}} + +只返回纯JSON,不要有其他文字。""", + "user": "用户的想法:{description}\n请生成6个书名建议" + }, + + "description": { + "system": """你是一位专业的小说创作顾问。 +用户已经确定了书名:{title} + +请生成6个精彩的小说简介,要求: +1. 符合书名风格 +2. 简洁有力,每个50-100字 +3. 包含核心冲突 +4. 涵盖不同的故事走向 + +返回JSON格式: +{{"prompt":"选择一个简介:","options":["简介1","简介2","简介3","简介4","简介5","简介6"]}} + +只返回纯JSON,不要有其他文字,不要换行。""", + "user": "书名是:{title},请生成6个简介选项" + }, + + "theme": { + "system": """你是一位专业的小说创作顾问。 +用户的小说信息: +- 书名:{title} +- 简介:{description} + +请生成6个深刻的主题选项,要求: +1. 符合书名和简介的风格 +2. 有深度和思想性 +3. 每个50-150字 +4. 涵盖不同角度(如:成长、复仇、救赎、探索等) + +返回JSON格式: +{{"prompt":"这本书的核心主题是什么?","options":["主题1","主题2","主题3","主题4","主题5","主题6"]}} + +只返回纯JSON,不要有其他文字,不要换行。""", + "user": "书名:{title}\n简介:{description}\n请生成6个主题选项" + }, + + "genre": { + "system": """你是一位专业的小说创作顾问。 +用户的小说信息: +- 书名:{title} +- 简介:{description} +- 主题:{theme} + +请生成6个合适的类型标签(每个2-4字),要求: +1. 符合小说整体风格 +2. 可以多选组合 + +常见类型:玄幻、都市、科幻、武侠、仙侠、历史、言情、悬疑、奇幻、修仙等 + +返回JSON格式: +{{"prompt":"选择类型标签(可多选):","options":["类型1","类型2","类型3","类型4","类型5","类型6"]}} + +只返回紧凑的纯JSON,不要换行,不要有其他文字。""", + "user": "书名:{title}\n简介:{description}\n主题:{theme}\n请生成6个类型标签" + } +} + + +def validate_options_response(result: Dict[str, Any], step: str, max_retries: int = 3) -> tuple[bool, str]: + """ + 校验AI返回的选项格式是否正确 + + Returns: + (is_valid, error_message) + """ + # 检查必需字段 + if "options" not in result: + return False, "缺少options字段" + + options = result.get("options", []) + + # 检查options是否为数组 + if not isinstance(options, list): + return False, "options必须是数组" + + # 检查数组长度 + if len(options) < 3: + return False, f"选项数量不足,至少需要3个,当前只有{len(options)}个" + + if len(options) > 10: + return False, f"选项数量过多,最多10个,当前有{len(options)}个" + + # 检查每个选项是否为字符串且不为空 + for i, option in enumerate(options): + if not isinstance(option, str): + return False, f"第{i+1}个选项不是字符串类型" + if not option.strip(): + return False, f"第{i+1}个选项为空" + if len(option) > 500: + return False, f"第{i+1}个选项过长(超过500字符)" + + # 根据不同步骤进行特定校验 + if step == "genre": + # 类型标签应该比较短 + for i, option in enumerate(options): + if len(option) > 10: + return False, f"类型标签【{option}】过长,应该在2-10字之间" + + return True, "" + + +@router.post("/generate-options") +async def generate_options( + data: Dict[str, Any], + ai_service: AIService = Depends(get_user_ai_service) +) -> Dict[str, Any]: + """ + 根据当前收集的信息生成下一步的选项建议(带自动重试) + + Request: + { + "step": "title", // title/description/theme/genre + "context": { + "title": "...", + "description": "...", + "theme": "..." + } + } + + Response: + { + "prompt": "引导语", + "options": ["选项1", "选项2", ...] + } + """ + max_retries = 3 + + for attempt in range(max_retries): + try: + step = data.get("step", "title") + context = data.get("context", {}) + + logger.info(f"灵感模式:生成{step}阶段的选项(第{attempt + 1}次尝试)") + + # 获取对应的提示词模板 + if step not in INSPIRATION_PROMPTS: + return { + "error": f"不支持的步骤: {step}", + "prompt": "", + "options": [] + } + + prompt_template = INSPIRATION_PROMPTS[step] + + # 准备格式化参数(提供默认值避免KeyError) + format_params = { + "title": context.get("title", ""), + "description": context.get("description", ""), + "theme": context.get("theme", "") + } + + # 格式化系统提示词 + system_prompt = prompt_template["system"].format(**format_params) + user_prompt = prompt_template["user"].format(**format_params) + + # 如果是重试,在提示词中强调格式要求 + if attempt > 0: + system_prompt += f"\n\n⚠️ 这是第{attempt + 1}次生成,请务必严格按照JSON格式返回,确保options数组包含6个有效选项!" + + # 调用AI生成选项 + logger.info(f"调用AI生成{step}选项...") + response = await ai_service.generate_text( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.8 # 提高创造性 + ) + + content = response.get("content", "") + logger.info(f"AI返回内容长度: {len(content)}") + + # 解析JSON + try: + # 清理可能的markdown标记 + cleaned_content = content.strip() + if cleaned_content.startswith('```json'): + cleaned_content = cleaned_content[7:].lstrip('\n\r') + elif cleaned_content.startswith('```'): + cleaned_content = cleaned_content[3:].lstrip('\n\r') + if cleaned_content.endswith('```'): + cleaned_content = cleaned_content[:-3].rstrip('\n\r') + cleaned_content = cleaned_content.strip() + + # 检查JSON是否完整 + if not cleaned_content.endswith('}'): + logger.warning(f"⚠️ JSON可能被截断,尝试补全...") + if '"options"' in cleaned_content: + if cleaned_content.count('[') > cleaned_content.count(']'): + cleaned_content += '"]}' + elif cleaned_content.count('{') > cleaned_content.count('}'): + cleaned_content += '}' + + result = json.loads(cleaned_content) + + # 校验返回格式 + is_valid, error_msg = validate_options_response(result, step) + + if not is_valid: + logger.warning(f"⚠️ 第{attempt + 1}次生成格式校验失败: {error_msg}") + if attempt < max_retries - 1: + logger.info("准备重试...") + continue # 重试 + else: + # 最后一次尝试也失败了 + return { + "prompt": f"请为【{step}】提供内容:", + "options": ["让AI重新生成", "我自己输入"], + "error": f"AI生成格式错误({error_msg}),已自动重试{max_retries}次,请手动重试或自己输入" + } + + logger.info(f"✅ 第{attempt + 1}次成功生成{len(result.get('options', []))}个有效选项") + return result + + except json.JSONDecodeError as e: + logger.error(f"第{attempt + 1}次JSON解析失败: {e}") + + if attempt < max_retries - 1: + logger.info("JSON解析失败,准备重试...") + continue # 重试 + else: + # 最后一次尝试也失败了 + return { + "prompt": f"请为【{step}】提供内容:", + "options": ["让AI重新生成", "我自己输入"], + "error": f"AI返回格式错误,已自动重试{max_retries}次,请手动重试或自己输入" + } + + except Exception as e: + logger.error(f"第{attempt + 1}次生成失败: {e}", exc_info=True) + if attempt < max_retries - 1: + logger.info("发生异常,准备重试...") + continue + else: + return { + "error": str(e), + "prompt": "生成失败,请重试", + "options": ["重新生成", "我自己输入"] + } + + # 理论上不会到这里 + return { + "error": "生成失败", + "prompt": "请重试", + "options": [] + } + + +@router.post("/quick-generate") +async def quick_generate( + data: Dict[str, Any], + ai_service: AIService = Depends(get_user_ai_service) +) -> Dict[str, Any]: + """ + 智能补全:根据用户已提供的部分信息,AI自动补全缺失字段 + + Request: + { + "title": "书名(可选)", + "description": "简介(可选)", + "theme": "主题(可选)", + "genre": ["类型1", "类型2"](可选) + } + + Response: + { + "title": "补全的书名", + "description": "补全的简介", + "theme": "补全的主题", + "genre": ["补全的类型"] + } + """ + try: + logger.info("灵感模式:智能补全") + + # 构建补全提示词 + existing_info = [] + if data.get("title"): + existing_info.append(f"- 书名:{data['title']}") + if data.get("description"): + existing_info.append(f"- 简介:{data['description']}") + if data.get("theme"): + existing_info.append(f"- 主题:{data['theme']}") + if data.get("genre"): + existing_info.append(f"- 类型:{', '.join(data['genre'])}") + + existing_text = "\n".join(existing_info) if existing_info else "暂无信息" + + system_prompt = """你是一位专业的小说创作顾问。用户提供了部分小说信息,请补全缺失的字段。 + +用户已提供的信息: +{existing} + +请生成完整的小说方案,包含: +1. title: 书名(3-6字,如果用户已提供则保持原样) +2. description: 简介(50-100字) +3. theme: 核心主题(30-50字) +4. genre: 类型标签数组(2-3个) + +返回JSON格式: +{{ + "title": "书名", + "description": "简介内容...", + "theme": "主题内容...", + "genre": ["类型1", "类型2"] +}} + +只返回纯JSON,不要有其他文字。""" + + user_prompt = "请补全小说信息" + + # 调用AI + response = await ai_service.generate_text( + prompt=user_prompt, + system_prompt=system_prompt.format(existing=existing_text), + temperature=0.7 + ) + + content = response.get("content", "") + + # 解析JSON + try: + cleaned_content = content.strip() + if cleaned_content.startswith('```json'): + cleaned_content = cleaned_content[7:].lstrip('\n\r') + elif cleaned_content.startswith('```'): + cleaned_content = cleaned_content[3:].lstrip('\n\r') + if cleaned_content.endswith('```'): + cleaned_content = cleaned_content[:-3].rstrip('\n\r') + cleaned_content = cleaned_content.strip() + + result = json.loads(cleaned_content) + + # 合并用户已提供的信息(用户输入优先) + final_result = { + "title": data.get("title") or result.get("title", ""), + "description": data.get("description") or result.get("description", ""), + "theme": data.get("theme") or result.get("theme", ""), + "genre": data.get("genre") or result.get("genre", []) + } + + logger.info(f"✅ 智能补全成功") + return final_result + + except json.JSONDecodeError as e: + logger.error(f"JSON解析失败: {e}") + raise Exception("AI返回格式错误,请重试") + + except Exception as e: + logger.error(f"智能补全失败: {e}", exc_info=True) + return { + "error": str(e) + } \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index dcaefa0..96505b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -135,7 +135,7 @@ from app.api import ( projects, outlines, characters, chapters, wizard_stream, relationships, organizations, auth, users, settings, writing_styles, memories, - mcp_plugins, admin + mcp_plugins, admin, inspiration ) app.include_router(auth.router, prefix="/api") @@ -145,6 +145,7 @@ app.include_router(admin.router, prefix="/api") app.include_router(projects.router, prefix="/api") app.include_router(wizard_stream.router, prefix="/api") +app.include_router(inspiration.router, prefix="/api") app.include_router(outlines.router, prefix="/api") app.include_router(characters.router, prefix="/api") app.include_router(chapters.router, prefix="/api") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1df1b24..23205a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import ProjectList from './pages/ProjectList'; import ProjectWizardNew from './pages/ProjectWizardNew'; +import Inspiration from './pages/Inspiration'; import ProjectDetail from './pages/ProjectDetail'; import WorldSetting from './pages/WorldSetting'; import Outline from './pages/Outline'; @@ -36,7 +37,9 @@ function App() { } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Inspiration.tsx b/frontend/src/pages/Inspiration.tsx new file mode 100644 index 0000000..624e1e7 --- /dev/null +++ b/frontend/src/pages/Inspiration.tsx @@ -0,0 +1,929 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Card, Input, Button, Space, Typography, message, Spin, Progress } from 'antd'; +import { SendOutlined, ArrowLeftOutlined, CheckCircleOutlined, LoadingOutlined, RocketOutlined } from '@ant-design/icons'; +import { inspirationApi, wizardStreamApi } from '../services/api'; +import type { ApiError } from '../types'; + +const { Title, Text, Paragraph } = Typography; +const { TextArea } = Input; + +type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'confirm' | 'generating' | 'complete'; + +interface Message { + type: 'ai' | 'user'; + content: string; + options?: string[]; + isMultiSelect?: boolean; +} + +interface WizardData { + title: string; + description: string; + theme: string; + genre: string[]; + narrative_perspective: string; +} + +const Inspiration: React.FC = () => { + const navigate = useNavigate(); + const [currentStep, setCurrentStep] = useState('idea'); + const [messages, setMessages] = useState([ + { + type: 'ai', + content: '你好!我是你的AI创作助手。让我们一起创作一部精彩的小说吧!\n\n请告诉我,你想写一本什么样的小说?', + } + ]); + const [inputValue, setInputValue] = useState(''); + const [loading, setLoading] = useState(false); + const [selectedOptions, setSelectedOptions] = useState([]); + + // 收集的数据 + const [wizardData, setWizardData] = useState>({}); + + // 项目生成状态 + const [projectId, setProjectId] = useState(''); + const [projectTitle, setProjectTitle] = useState(''); + const [progress, setProgress] = useState(0); + const [progressMessage, setProgressMessage] = useState(''); + const [generationSteps, setGenerationSteps] = useState<{ + worldBuilding: 'pending' | 'processing' | 'completed' | 'error'; + characters: 'pending' | 'processing' | 'completed' | 'error'; + outline: 'pending' | 'processing' | 'completed' | 'error'; + }>({ + worldBuilding: 'pending', + characters: 'pending', + outline: 'pending' + }); + + // 滚动容器引用 + const messagesEndRef = useRef(null); + const chatContainerRef = useRef(null); + + // 记录上次失败的请求参数,用于重试 + const [lastFailedRequest, setLastFailedRequest] = useState<{ + step: 'title' | 'description' | 'theme' | 'genre'; + context: Partial; + } | null>(null); + + // 自动滚动到底部 - 使用更丝滑的方式 + const scrollToBottom = () => { + // 使用 setTimeout 确保 DOM 已更新 + setTimeout(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: 'smooth' + }); + } + }, 100); + }; + + // 当消息更新时自动滚动 + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // 重试生成 + const handleRetry = async () => { + if (!lastFailedRequest) return; + + setLoading(true); + try { + const response = await inspirationApi.generateOptions({ + step: lastFailedRequest.step, + context: lastFailedRequest.context + }); + + if (response.error) { + message.error(response.error); + return; + } + + // 移除失败消息,添加成功的AI消息 + setMessages(prev => { + const newMessages = [...prev]; + if (newMessages[newMessages.length - 1].type === 'ai' && + (newMessages[newMessages.length - 1].content.includes('生成失败') || + newMessages[newMessages.length - 1].content.includes('出错了'))) { + newMessages.pop(); + } + return newMessages; + }); + + const aiMessage: Message = { + type: 'ai', + content: response.prompt || '请选择一个选项,或者输入你自己的:', + options: response.options || [], + isMultiSelect: lastFailedRequest.step === 'genre' + }; + setMessages(prev => [...prev, aiMessage]); + setLastFailedRequest(null); + } catch (error: any) { + console.error('重试失败:', error); + message.error('重试失败,请稍后再试'); + } finally { + setLoading(false); + } + }; + + // 步骤顺序 + const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'confirm']; + + const handleSendMessage = async () => { + if (!inputValue.trim()) { + message.warning('请输入内容'); + return; + } + + const userMessage: Message = { + type: 'user', + content: inputValue, + }; + setMessages(prev => [...prev, userMessage]); + + const userInput = inputValue; + setInputValue(''); + setLoading(true); + + try { + if (currentStep === 'idea') { + const requestData = { + step: 'title' as const, + context: { description: userInput } + }; + + const response = await inspirationApi.generateOptions(requestData); + + // 前端格式校验:检查是否有错误或选项数量不足 + if (response.error || !response.options || response.options.length < 3) { + const errorMessage: Message = { + type: 'ai', + content: response.error + ? `生成书名时出错:${response.error}\n\n你可以选择:` + : `生成的选项格式不正确(至少需要3个有效选项)\n\n你可以选择:`, + options: response.options && response.options.length > 0 ? response.options : ['重新生成', '我自己输入书名'] + }; + setMessages(prev => [...prev, errorMessage]); + setLastFailedRequest(requestData); + return; + } + + const aiMessage: Message = { + type: 'ai', + content: response.prompt || '请选择一个书名,或者输入你自己的:', + options: response.options + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('title'); + setLastFailedRequest(null); + } else { + handleCustomInput(userInput); + } + } catch (error: any) { + console.error('发送消息失败:', error); + message.error(error.response?.data?.detail || '生成失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleSelectOption = async (option: string) => { + if (option === '重新生成' && lastFailedRequest) { + await handleRetry(); + return; + } + + if (option === '我自己输入书名' || option === '我自己输入') { + message.info('请在下方输入框中输入您的内容'); + return; + } + + if (currentStep === 'genre') { + const newSelected = selectedOptions.includes(option) + ? selectedOptions.filter(o => o !== option) + : [...selectedOptions, option]; + setSelectedOptions(newSelected); + return; + } + + if (currentStep === 'perspective') { + // 叙事视角是单选 + const userMessage: Message = { + type: 'user', + content: option, + }; + setMessages(prev => [...prev, userMessage]); + + const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData; + setWizardData(updatedData); + + // 显示预览和确认选项 + const summary = ` +太棒了!你的小说设定已完成,请确认: + +📖 书名:${updatedData.title} +📝 简介:${updatedData.description} +🎯 主题:${updatedData.theme} +🏷️ 类型:${updatedData.genre.join('、')} +👁️ 视角:${updatedData.narrative_perspective} + +请选择下一步操作: + `.trim(); + + const aiMessage: Message = { + type: 'ai', + content: summary, + options: ['✅ 确认创建', '🔄 重新开始'] + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('confirm'); + return; + } + + if (currentStep === 'confirm') { + if (option === '✅ 确认创建') { + const userMessage: Message = { + type: 'user', + content: '确认创建', + }; + setMessages(prev => [...prev, userMessage]); + + const aiMessage: Message = { + type: 'ai', + content: '好的!正在为你创建项目,这可能需要几分钟时间...' + }; + setMessages(prev => [...prev, aiMessage]); + + // 开始生成项目 + await handleAutoGenerate(wizardData as WizardData); + return; + } else if (option === '🔄 重新开始') { + handleRestart(); + return; + } + } + + const userMessage: Message = { + type: 'user', + content: option, + }; + setMessages(prev => [...prev, userMessage]); + setLoading(true); + + try { + const updatedData = { ...wizardData }; + if (currentStep === 'title') { + updatedData.title = option; + } else if (currentStep === 'description') { + updatedData.description = option; + } else if (currentStep === 'theme') { + updatedData.theme = option; + } + setWizardData(updatedData); + + await generateNextStep(updatedData); + } catch (error: any) { + console.error('选择选项失败:', error); + message.error(error.response?.data?.detail || '生成失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleCustomInput = async (input: string) => { + const updatedData = { ...wizardData }; + + if (currentStep === 'title') { + updatedData.title = input; + } else if (currentStep === 'description') { + updatedData.description = input; + } else if (currentStep === 'theme') { + updatedData.theme = input; + } else if (currentStep === 'genre') { + updatedData.genre = [input]; + } else if (currentStep === 'perspective') { + updatedData.narrative_perspective = input; + } + + setWizardData(updatedData); + await generateNextStep(updatedData); + }; + + // 自动化生成项目流程 + const handleAutoGenerate = async (data: WizardData) => { + try { + setLoading(true); + setCurrentStep('generating'); + setProjectTitle(data.title); + setProgress(0); + setProgressMessage('开始创建项目...'); + + // 步骤1: 生成世界观并创建项目 + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' })); + setProgressMessage('正在生成世界观...'); + + const worldResult = await wizardStreamApi.generateWorldBuildingStream( + { + title: data.title, + description: data.description, + theme: data.theme, + genre: data.genre.join('、'), + narrative_perspective: data.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); + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' })); + }, + onError: (error) => { + setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' })); + throw new Error(error); + }, + onComplete: () => { + console.log('世界观生成完成'); + } + } + ); + + if (!worldResult?.project_id) { + throw new Error('项目创建失败'); + } + + const createdProjectId = worldResult.project_id; + setProjectId(createdProjectId); + + // 步骤2: 生成角色 + setGenerationSteps(prev => ({ ...prev, characters: 'processing' })); + setProgressMessage('正在生成角色...'); + + await wizardStreamApi.generateCharactersStream( + { + project_id: createdProjectId, + count: 5, + world_context: { + time_period: worldResult.time_period || '', + location: worldResult.location || '', + atmosphere: worldResult.atmosphere || '', + rules: worldResult.rules || '', + }, + theme: data.theme, + genre: data.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' })); + }, + onError: (error) => { + setGenerationSteps(prev => ({ ...prev, characters: 'error' })); + throw new Error(error); + }, + onComplete: () => { + console.log('角色生成完成'); + } + } + ); + + // 步骤3: 生成大纲 + setGenerationSteps(prev => ({ ...prev, outline: 'processing' })); + setProgressMessage('正在生成大纲...'); + + await wizardStreamApi.generateCompleteOutlineStream( + { + project_id: createdProjectId, + chapter_count: 5, + narrative_perspective: data.narrative_perspective, + target_words: 100000, + }, + { + onProgress: (msg, prog) => { + setProgress(66 + Math.floor(prog / 3)); + setProgressMessage(msg); + }, + onResult: () => { + console.log('大纲生成完成'); + setGenerationSteps(prev => ({ ...prev, outline: 'completed' })); + }, + onError: (error) => { + setGenerationSteps(prev => ({ ...prev, outline: 'error' })); + throw new Error(error); + }, + onComplete: () => { + console.log('大纲生成完成'); + } + } + ); + + // 全部完成 + setProgress(100); + setProgressMessage('项目创建完成!'); + setCurrentStep('complete'); + message.success('项目创建成功!'); + + } catch (error) { + const apiError = error as ApiError; + message.error('创建项目失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误')); + setCurrentStep('genre'); + setGenerationSteps({ + worldBuilding: 'pending', + characters: 'pending', + outline: 'pending' + }); + } finally { + setLoading(false); + } + }; + + const handleConfirmGenres = async () => { + if (selectedOptions.length === 0) { + message.warning('请至少选择一个类型'); + return; + } + + const userMessage: Message = { + type: 'user', + content: selectedOptions.join('、'), + }; + setMessages(prev => [...prev, userMessage]); + + const updatedData = { ...wizardData, genre: selectedOptions }; + setWizardData(updatedData); + setSelectedOptions([]); + + // 进入叙事视角选择 + setLoading(true); + try { + const aiMessage: Message = { + type: 'ai', + content: '很好!最后一步,请选择小说的叙事视角:', + options: ['第一人称', '第三人称', '全知视角'] + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('perspective'); + } finally { + setLoading(false); + } + }; + + const generateNextStep = async (data: Partial) => { + const currentIndex = stepOrder.indexOf(currentStep); + const nextStep = stepOrder[currentIndex + 1]; + + if (nextStep === 'description') { + const requestData = { + step: 'description' as const, + context: { title: data.title } + }; + const response = await inspirationApi.generateOptions(requestData); + + // 前端格式校验 + if (response.error || !response.options || response.options.length < 3) { + const errorMessage: Message = { + type: 'ai', + content: response.error + ? `生成简介时出错:${response.error}\n\n你可以选择:` + : `生成的选项格式不正确(至少需要3个有效选项)\n\n你可以选择:`, + options: response.options && response.options.length > 0 ? response.options : ['重新生成', '我自己输入'] + }; + setMessages(prev => [...prev, errorMessage]); + setLastFailedRequest(requestData); + return; + } + + const aiMessage: Message = { + type: 'ai', + content: response.prompt || '请选择一个简介,或者输入你自己的:', + options: response.options + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('description'); + setLastFailedRequest(null); + + } else if (nextStep === 'theme') { + const requestData = { + step: 'theme' as const, + context: { title: data.title, description: data.description } + }; + const response = await inspirationApi.generateOptions(requestData); + + // 前端格式校验 + if (response.error || !response.options || response.options.length < 3) { + const errorMessage: Message = { + type: 'ai', + content: response.error + ? `生成主题时出错:${response.error}\n\n你可以选择:` + : `生成的选项格式不正确(至少需要3个有效选项)\n\n你可以选择:`, + options: response.options && response.options.length > 0 ? response.options : ['重新生成', '我自己输入'] + }; + setMessages(prev => [...prev, errorMessage]); + setLastFailedRequest(requestData); + return; + } + + const aiMessage: Message = { + type: 'ai', + content: response.prompt || '请选择一个主题,或者输入你自己的:', + options: response.options + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('theme'); + setLastFailedRequest(null); + + } else if (nextStep === 'genre') { + const requestData = { + step: 'genre' as const, + context: { + title: data.title, + description: data.description, + theme: data.theme + } + }; + const response = await inspirationApi.generateOptions(requestData); + + // 前端格式校验 + if (response.error || !response.options || response.options.length < 3) { + const errorMessage: Message = { + type: 'ai', + content: response.error + ? `生成类型时出错:${response.error}\n\n你可以选择:` + : `生成的选项格式不正确(至少需要3个有效选项)\n\n你可以选择:`, + options: response.options && response.options.length > 0 ? response.options : ['重新生成', '我自己输入'], + isMultiSelect: false + }; + setMessages(prev => [...prev, errorMessage]); + setLastFailedRequest(requestData); + return; + } + + const aiMessage: Message = { + type: 'ai', + content: response.prompt || '请选择类型标签(可多选):', + options: response.options, + isMultiSelect: true + }; + setMessages(prev => [...prev, aiMessage]); + setCurrentStep('genre'); + setLastFailedRequest(null); + } + }; + + const handleRestart = () => { + setCurrentStep('idea'); + setMessages([ + { + type: 'ai', + content: '好的,让我们重新开始!\n\n请告诉我,你想写一本什么样的小说?', + } + ]); + setWizardData({}); + setSelectedOptions([]); + setLoading(false); + }; + + const handleBack = () => { + navigate('/projects'); + }; + + // 渲染生成进度页面 + const renderGenerating = () => { + const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => { + if (step === 'completed') return { icon: , color: '#52c41a' }; + if (step === 'processing') return { icon: , color: '#1890ff' }; + if (step === 'error') return { icon: '✗', color: '#ff4d4f' }; + return { icon: '○', color: '#d9d9d9' }; + }; + + return ( +
+ + 正在为《{projectTitle}》生成内容 + + + + + + + {progressMessage} + + + + {[ + { key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding }, + { key: 'characters', label: '生成角色', step: generationSteps.characters }, + { key: 'outline', label: '生成大纲', step: generationSteps.outline }, + ].map(({ key, label, step }) => { + const status = getStepStatus(step); + return ( +
+ + {label} + + + {status.icon} + +
+ ); + })} +
+
+ + + 请耐心等待,AI正在为您精心创作... + +
+ ); + }; + + // 渲染完成页面 + const renderComplete = () => ( +
+ +
+ ✓ +
+ + 项目创建完成! + + + 《{projectTitle}》已成功创建,包含完整的世界观、角色和开局大纲 + + + + + + +
+
+ ); + + // 渲染对话界面 + const renderChat = () => ( + <> + {/* 对话区域 */} + + + {messages.map((msg, index) => ( +
+
+ + {msg.content} + + + {/* 选项卡片 */} + {msg.options && msg.options.length > 0 && ( + + {msg.options.map((option, optIndex) => ( + handleSelectOption(option)} + style={{ + cursor: 'pointer', + border: msg.isMultiSelect && selectedOptions.includes(option) + ? '2px solid #1890ff' + : '1px solid #d9d9d9', + background: msg.isMultiSelect && selectedOptions.includes(option) + ? '#e6f7ff' + : '#fff', + animation: 'floatIn 0.6s ease-out', + animationDelay: `${optIndex * 0.1}s`, + animationFillMode: 'both', + transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', + }} + onMouseEnter={(e) => { + e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = 'translateY(0) scale(1)'; + e.currentTarget.style.boxShadow = 'none'; + }} + > + {option} + + ))} + + {/* 多选确认按钮 */} + {msg.isMultiSelect && ( + + )} + + )} +
+
+ ))} + + {loading && ( +
+ +
+ )} + + {/* 滚动锚点 */} +
+ + + + {/* 输入区域 */} + + +