import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Card, Input, Button, Space, Typography, message, Spin, Modal, theme } from 'antd'; import { SendOutlined, ArrowLeftOutlined, ReloadOutlined } from '@ant-design/icons'; import { inspirationApi } from '../services/api'; import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator'; const { Title, Text, Paragraph } = Typography; const { TextArea } = Input; type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'outline_mode' | 'confirm' | 'generating' | 'complete'; interface Message { type: 'ai' | 'user'; content: string; options?: string[]; isMultiSelect?: boolean; optionsDisabled?: boolean; // 标记选项是否已禁用 canRefine?: boolean; // 是否可以优化(用于支持多轮对话) step?: Step; // 当前步骤(用于反馈) } interface WizardData { title: string; description: string; theme: string; genre: string[]; narrative_perspective: string; outline_mode: 'one-to-one' | 'one-to-many'; } // 缓存数据接口 interface CacheData { messages: Message[]; currentStep: Step; wizardData: Partial; initialIdea: string; selectedOptions: string[]; lastFailedRequest: { step: 'title' | 'description' | 'theme' | 'genre'; context: Partial; } | null; timestamp: number; } // 缓存键 const CACHE_KEY = 'inspiration_conversation_cache'; // 缓存有效期:24小时 const CACHE_EXPIRY = 24 * 60 * 60 * 1000; const Inspiration: React.FC = () => { const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState('idea'); const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); const { token } = theme.useToken(); useEffect(() => { const handleResize = () => { setIsMobile(window.innerWidth <= 768); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); 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 [initialIdea, setInitialIdea] = useState(''); // 反馈相关状态 const [feedbackValue, setFeedbackValue] = useState(''); const [showFeedbackInput, setShowFeedbackInput] = useState(null); // 当前显示反馈输入的消息索引 const [refining, setRefining] = useState(false); // 正在优化选项 // 生成配置 const [generationConfig, setGenerationConfig] = useState(null); // Modal hook const [modal, contextHolder] = Modal.useModal(); // 滚动容器引用 const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); // 记录上次失败的请求参数,用于重试 const [lastFailedRequest, setLastFailedRequest] = useState<{ step: 'title' | 'description' | 'theme' | 'genre'; context: Partial; } | null>(null); // 标记是否已经加载缓存 const [cacheLoaded, setCacheLoaded] = useState(false); // ==================== 缓存管理函数 ==================== // 清除缓存 const clearCache = useCallback(() => { try { localStorage.removeItem(CACHE_KEY); console.log('🗑️ 缓存已清除'); } catch (error) { console.error('清除缓存失败:', error); } }, []); // 保存到缓存 const saveToCache = useCallback(() => { try { // 只在对话阶段保存,生成阶段不保存 if (currentStep === 'generating' || currentStep === 'complete') { return; } // 只有用户有输入时才保存(至少两条消息:AI问候+用户回复) if (messages.length <= 1) { return; } const cacheData: CacheData = { messages, currentStep, wizardData, initialIdea, selectedOptions, lastFailedRequest, timestamp: Date.now() }; localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); console.log('💾 对话已自动保存'); } catch (error) { console.error('保存缓存失败:', error); } }, [currentStep, messages, wizardData, initialIdea, selectedOptions, lastFailedRequest]); // 从缓存恢复 const restoreFromCache = useCallback((): boolean => { try { const cached = localStorage.getItem(CACHE_KEY); if (!cached) { return false; } const cacheData: CacheData = JSON.parse(cached); const age = Date.now() - cacheData.timestamp; // 检查缓存是否过期 if (age > CACHE_EXPIRY) { console.log('⏰ 缓存已过期,清除'); clearCache(); return false; } // 必须有有效的对话数据 if (!cacheData.messages || cacheData.messages.length <= 1) { return false; } // 恢复所有状态 setMessages(cacheData.messages); setCurrentStep(cacheData.currentStep); setWizardData(cacheData.wizardData); setInitialIdea(cacheData.initialIdea); setSelectedOptions(cacheData.selectedOptions); // 恢复失败请求信息,确保"重新生成"按钮可用 if (cacheData.lastFailedRequest) { setLastFailedRequest(cacheData.lastFailedRequest); } console.log('✅ 已恢复上次的对话进度'); message.success('已恢复上次的对话进度', 2); return true; } catch (error) { console.error('恢复缓存失败:', error); clearCache(); return false; } }, [clearCache]); // ==================== 组件挂载时恢复缓存 ==================== useEffect(() => { if (!cacheLoaded) { restoreFromCache(); setCacheLoaded(true); } }, [cacheLoaded, restoreFromCache]); // ==================== 自动保存:状态变化时保存 ==================== useEffect(() => { // 防抖保存 const timer = setTimeout(() => { if (cacheLoaded) { saveToCache(); } }, 500); return () => clearTimeout(timer); }, [messages, currentStep, wizardData, initialIdea, selectedOptions, lastFailedRequest, cacheLoaded, saveToCache]); // 自动滚动到底部 const scrollToBottom = () => { 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; } 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: unknown) { console.error('重试失败:', error); message.error('重试失败,请稍后再试'); } finally { setLoading(false); } }; // 处理用户反馈,重新生成选项 const handleRefineOptions = async (messageIndex: number, feedback: string) => { if (!feedback.trim()) { message.warning('请输入您的反馈意见'); return; } const targetMessage = messages[messageIndex]; if (!targetMessage.options || !targetMessage.step) { return; } setRefining(true); setShowFeedbackInput(null); setFeedbackValue(''); // 先禁用旧的选项 setMessages(prev => { const newMessages = [...prev]; if (newMessages[messageIndex]) { newMessages[messageIndex] = { ...newMessages[messageIndex], optionsDisabled: true, canRefine: false, // 同时禁用反馈功能 }; } return newMessages; }); try { // 添加用户反馈消息 const feedbackMessage: Message = { type: 'user', content: `💭 ${feedback}`, }; setMessages(prev => [...prev, feedbackMessage]); const step = targetMessage.step as 'title' | 'description' | 'theme' | 'genre'; // 构建上下文 const context: Partial & { initial_idea?: string } = { initial_idea: initialIdea, title: wizardData.title, description: wizardData.description, theme: wizardData.theme, }; // 调用refine接口 const response = await inspirationApi.refineOptions({ step, context, feedback, previous_options: targetMessage.options, }); if (response.error) { message.error(response.error); return; } // 添加新的AI消息 const aiMessage: Message = { type: 'ai', content: response.prompt || `根据您的反馈,我重新生成了一些${step === 'title' ? '书名' : step === 'description' ? '简介' : step === 'theme' ? '主题' : '类型'}选项:`, options: response.options || [], isMultiSelect: step === 'genre', canRefine: true, step: step, }; setMessages(prev => [...prev, aiMessage]); message.success('已根据您的反馈重新生成选项'); } catch (error: unknown) { console.error('优化选项失败:', error); const errMsg = error instanceof Error ? error.message : '优化失败,请重试'; const axiosError = error as { response?: { data?: { detail?: string } } }; message.error(axiosError.response?.data?.detail || errMsg); } finally { setRefining(false); } }; // 步骤顺序 const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'outline_mode', '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') { setInitialIdea(userInput); const requestData = { step: 'title' as const, context: { initial_idea: userInput, 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, canRefine: true, step: 'title' }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('title'); setLastFailedRequest(null); } else { await handleCustomInput(userInput); } } catch (error: unknown) { console.error('发送消息失败:', error); const errMsg = error instanceof Error ? error.message : '生成失败,请重试'; const axiosError = error as { response?: { data?: { detail?: string } } }; message.error(axiosError.response?.data?.detail || errMsg); } 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; } // 立即禁用当前消息的选项(单选场景) setMessages(prev => { const newMessages = [...prev]; const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop(); if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) { newMessages[lastAiMessageIndex] = { ...newMessages[lastAiMessageIndex], optionsDisabled: true }; } return newMessages; }); if (currentStep === 'perspective') { const userMessage: Message = { type: 'user', content: option, }; setMessages(prev => [...prev, userMessage]); const updatedData = { ...wizardData, narrative_perspective: option }; setWizardData(updatedData); // 询问大纲模式 const aiMessage: Message = { type: 'ai', content: `很好!现在请选择你想要的大纲模式: 📋 一对一模式:传统模式,一个大纲对应一个章节,适合结构清晰、章节独立的小说。 📚 一对多模式:细化模式,一个大纲可以展开成多个章节,适合需要详细展开情节的小说。 请选择:`, options: ['📋 一对一模式', '📚 一对多模式'] }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('outline_mode'); return; } if (currentStep === 'outline_mode') { const userMessage: Message = { type: 'user', content: option, }; setMessages(prev => [...prev, userMessage]); // 将选项转换为实际的模式值 const modeValue: 'one-to-one' | 'one-to-many' = option === '📋 一对一模式' ? 'one-to-one' : 'one-to-many'; const updatedData = { ...wizardData, outline_mode: modeValue, genre: wizardData.genre || [] } as WizardData; setWizardData(updatedData); // 显示摘要 const modeText = modeValue === 'one-to-one' ? '一对一模式' : '一对多模式'; const summary = ` 太棒了!你的小说设定已完成,请确认: 📖 书名:${updatedData.title} 📝 简介:${updatedData.description} 🎯 主题:${updatedData.theme} 🏷️ 类型:${updatedData.genre.join('、')} 👁️ 视角:${updatedData.narrative_perspective} 📋 大纲模式:${modeText} 请选择下一步操作: `.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]); // 清除缓存(对话完成,进入生成阶段) clearCache(); // 开始生成项目 const data = wizardData as WizardData; const config: GenerationConfig = { title: data.title, description: data.description, theme: data.theme, genre: data.genre, narrative_perspective: data.narrative_perspective, target_words: 100000, chapter_count: 3, character_count: 5, outline_mode: data.outline_mode, }; setGenerationConfig(config); setCurrentStep('generating'); 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: unknown) { console.error('选择选项失败:', error); const errMsg = error instanceof Error ? error.message : '生成失败,请重试'; const axiosError = error as { response?: { data?: { detail?: string } } }; message.error(axiosError.response?.data?.detail || errMsg); } finally { setLoading(false); } }; const handleCustomInput = async (input: string) => { setLoading(true); try { 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); // 直接进入大纲模式选择 const aiMessage: Message = { type: 'ai', content: `很好!现在请选择你想要的大纲模式: 📋 一对一模式:传统模式,一个大纲对应一个章节,适合结构清晰、章节独立的小说。 📚 一对多模式:细化模式,一个大纲可以展开成多个章节,适合需要详细展开情节的小说。 请选择:`, options: ['📋 一对一模式', '📚 一对多模式'] }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('outline_mode'); setLoading(false); return; } else if (currentStep === 'outline_mode') { // 大纲模式不支持自定义输入 message.warning('请从选项中选择一个大纲模式'); setLoading(false); return; } setWizardData(updatedData); await generateNextStep(updatedData); } catch (error: unknown) { console.error('处理自定义输入失败:', error); const errMsg = error instanceof Error ? error.message : '处理失败,请重试'; const axiosError = error as { response?: { data?: { detail?: string } } }; message.error(axiosError.response?.data?.detail || errMsg); } finally { setLoading(false); } }; const handleConfirmGenres = async () => { if (selectedOptions.length === 0) { message.warning('请至少选择一个类型'); return; } // 禁用类型选择的选项 setMessages(prev => { const newMessages = [...prev]; const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop(); if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) { newMessages[lastAiMessageIndex] = { ...newMessages[lastAiMessageIndex], optionsDisabled: true }; } return newMessages; }); 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 === 'perspective') { // genre 步骤完成后,进入 perspective const aiMessage: Message = { type: 'ai', content: '很好!接下来,请选择小说的叙事视角:', options: ['第一人称', '第三人称', '全知视角'] }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('perspective'); } else if (nextStep === 'description') { const requestData = { step: 'description' as const, context: { initial_idea: initialIdea, 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, canRefine: true, step: 'description' }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('description'); setLastFailedRequest(null); } else if (nextStep === 'theme') { const requestData = { step: 'theme' as const, context: { initial_idea: initialIdea, 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, canRefine: true, step: 'theme' }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('theme'); setLastFailedRequest(null); } else if (nextStep === 'genre') { const requestData = { step: 'genre' as const, context: { initial_idea: initialIdea, 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, canRefine: true, step: 'genre' }; setMessages(prev => [...prev, aiMessage]); setCurrentStep('genre'); setLastFailedRequest(null); } }; const handleRestart = () => { // 清除缓存 clearCache(); setCurrentStep('idea'); setMessages([ { type: 'ai', content: '好的,让我们重新开始!\n\n请告诉我,你想写一本什么样的小说?', } ]); setWizardData({}); setInitialIdea(''); setSelectedOptions([]); setLoading(false); }; const handleBack = () => { navigate('/projects'); }; // 生成完成回调 const handleComplete = (projectId: string) => { console.log('灵感模式项目创建完成:', projectId); // 确保清除缓存 clearCache(); setCurrentStep('complete'); }; // 返回对话界面 const handleBackToChat = () => { clearCache(); setCurrentStep('idea'); setGenerationConfig(null); handleRestart(); }; // 渲染对话界面 const renderChat = () => ( <> {messages.map((msg, index) => (
{msg.content} {msg.options && msg.options.length > 0 && ( {msg.options.map((option, optIndex) => ( !msg.optionsDisabled && handleSelectOption(option)} style={{ cursor: msg.optionsDisabled ? 'not-allowed' : 'pointer', border: msg.isMultiSelect && selectedOptions.includes(option) ? `2px solid ${token.colorPrimary}` : `1px solid ${token.colorBorder}`, background: msg.optionsDisabled ? token.colorBgLayout : msg.isMultiSelect && selectedOptions.includes(option) ? token.colorPrimaryBg : token.colorBgContainer, opacity: msg.optionsDisabled ? 0.6 : 1, 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) => { if (!msg.optionsDisabled) { e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)'; e.currentTarget.style.boxShadow = `0 8px 22px color-mix(in srgb, ${token.colorTextBase} 14%, transparent)`; } }} onMouseLeave={(e) => { if (!msg.optionsDisabled) { e.currentTarget.style.transform = 'translateY(0) scale(1)'; e.currentTarget.style.boxShadow = 'none'; } }} > {option} ))} {msg.isMultiSelect && ( )} {/* 反馈优化区域 - 新增 */} {msg.canRefine && !msg.optionsDisabled && !msg.isMultiSelect && (
{showFeedbackInput === index ? (